pull/7309/head
Your Name 1 week ago
parent 7edc6fe0bb
commit 163cd9f589

@ -84,3 +84,46 @@ FILESYSTEM_CLOUD=s3
## Optional (WHEN_SUPPORTED (default) / WHEN_REQUIRED) https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html ## Optional (WHEN_SUPPORTED (default) / WHEN_REQUIRED) https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html
# AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_SUPPORTED # AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_SUPPORTED
# AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_SUPPORTED # AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_SUPPORTED
#######################################
# Captcha
#######################################
# Master switch. Must be true for any captcha to appear.
CAPTCHA_ENABLED=false
# Active provider: hcaptcha (default), turnstile, or cap
CAPTCHA_DRIVER=hcaptcha
# Per-surface toggles (each also requires CAPTCHA_ENABLED=true)
CAPTCHA_ENABLED_ON_LOGIN=false
CAPTCHA_ENABLED_ON_REGISTER=false
CAPTCHA_ENABLED_ON_FORGOT_PASSWORD=false
CAPTCHA_ENABLED_ON_PASSWORD_RESET=false
CAPTCHA_ENABLED_ON_CURATED_REGISTER=false
# Show a captcha on login only after N failed attempts
CAPTCHA_TRIGGERS_LOGIN_ENABLED=false
CAPTCHA_TRIGGERS_LOGIN_ATTEMPTS=2
# --- hCaptcha (driver: hcaptcha) ---
CAPTCHA_SECRET=
CAPTCHA_SITEKEY=
# --- Cloudflare Turnstile (driver: turnstile) ---
CAPTCHA_TURNSTILE_SITEKEY=
CAPTCHA_TURNSTILE_SECRET=
CAPTCHA_TURNSTILE_TIMEOUT=5
# Let requests through on network/5xx errors instead of blocking
CAPTCHA_TURNSTILE_FAIL_OPEN=false
# --- Cap, self-hosted proof-of-work (driver: cap) ---
# Full URL of your Cap instance including the site key (trailing slash required)
CAP_ENDPOINT=
CAP_SECRET=
CAP_TOKEN_FIELD=cap-token
CAP_TIMEOUT=5
CAP_FAIL_OPEN=false
# @cap.js/widget version from the jsDelivr CDN. Leave blank to track "latest".
CAP_WIDGET_VERSION==5
CAPTCHA_TURNSTILE_FAIL_OPEN=false
# Cap widget version served from the jsDelivr CDN
CAP_WIDGET_VERSION=0.1.57

@ -0,0 +1,47 @@
<?php
namespace App\Contracts;
/**
* Contract every captcha provider (hCaptcha, Turnstile, Cap, ...) must implement
* so the rest of the application can stay provider-agnostic.
*/
interface CaptchaDriver
{
/**
* The machine name of the driver (e.g. "hcaptcha", "turnstile", "cap").
*/
public function name(): string;
/**
* Whether the driver has the credentials/config it needs to operate.
*/
public function isConfigured(): bool;
/**
* The name of the request field that carries the response token for this
* provider (e.g. "h-captcha-response", "cf-turnstile-response", "cap-token").
*/
public function responseField(): string;
/**
* Verify a submitted request against the provider.
*
* Implementations should pull the response token out of the given input
* array using responseField().
*/
public function verify(array $input): bool;
/**
* Render the widget markup to embed in a form. Optional HTML attributes
* (e.g. ['data-theme' => 'dark']) may be passed through to the widget.
*/
public function render(array $attributes = []): string;
/**
* Any <script>/<link> tags the widget needs. Returned separately so callers
* can place them in <head> or before </body> as appropriate. May be empty
* when render() already includes everything.
*/
public function scripts(): string;
}

@ -0,0 +1,28 @@
<?php
namespace App\Facades;
use App\Contracts\CaptchaDriver;
use App\Services\Captcha\CaptchaManager;
use Illuminate\Support\Facades\Facade;
/**
* Provider-agnostic captcha facade.
*
* @method static CaptchaDriver active()
* @method static bool enabled()
* @method static bool activeOn(string $surface)
* @method static bool activeOnLogin()
* @method static array available()
* @method static array rules()
* @method static CaptchaDriver driver(string|null $driver = null)
*
* @see CaptchaManager
*/
class Captcha extends Facade
{
protected static function getFacadeAccessor(): string
{
return 'captcha.manager';
}
}

@ -679,10 +679,13 @@ trait AdminSettingsController
'allow_post_embeds' => 'required', 'allow_post_embeds' => 'required',
'allow_profile_embeds' => 'required', 'allow_profile_embeds' => 'required',
'captcha_enabled' => 'required', 'captcha_enabled' => 'required',
'captcha_driver' => 'nullable|in:hcaptcha,turnstile,cap',
'captcha_on_login' => 'required_if_accepted:captcha_enabled', 'captcha_on_login' => 'required_if_accepted:captcha_enabled',
'captcha_on_register' => 'required_if_accepted:captcha_enabled', 'captcha_on_register' => 'required_if_accepted:captcha_enabled',
'captcha_secret' => 'required_if_accepted:captcha_enabled', // Provider credentials are optional here (masked values are sent on
'captcha_sitekey' => 'required_if_accepted:captcha_enabled', // re-save); the save logic below only writes fresh, non-masked values.
'captcha_hcaptcha_secret' => 'nullable|string',
'captcha_hcaptcha_sitekey' => 'nullable|string',
'custom_emoji_enabled' => 'required', 'custom_emoji_enabled' => 'required',
]); ]);
@ -696,16 +699,51 @@ trait AdminSettingsController
ConfigCacheService::put('federation.custom_emoji.enabled', $request->boolean('custom_emoji_enabled')); ConfigCacheService::put('federation.custom_emoji.enabled', $request->boolean('custom_emoji_enabled'));
$captcha = $request->boolean('captcha_enabled'); $captcha = $request->boolean('captcha_enabled');
if ($captcha) { if ($captcha) {
$secret = $request->input('captcha_secret'); // Persist the selected provider (defaults to hcaptcha).
$sitekey = $request->input('captcha_sitekey'); $driver = $request->input('captcha_driver', 'hcaptcha');
if (config_cache('captcha.secret') != $secret && strpos($secret, '*') === false) { if (! in_array($driver, ['hcaptcha', 'turnstile', 'cap'], true)) {
ConfigCacheService::put('captcha.secret', $secret); $driver = 'hcaptcha';
} }
if (config_cache('captcha.sitekey') != $sitekey && strpos($sitekey, '*') === false) { ConfigCacheService::put('captcha.driver', $driver);
ConfigCacheService::put('captcha.sitekey', $sitekey);
// Only overwrite a secret/credential when a fresh (non-masked,
// non-empty) value is submitted. Masked values contain '*'.
$putIfChanged = function (string $key, ?string $value): void {
if ($value === null || $value === '' || str_contains($value, '*')) {
return;
}
if (config_cache($key) != $value) {
ConfigCacheService::put($key, $value);
}
};
// hCaptcha credentials. Persist to the canonical captcha.hcaptcha.*
// keys and mirror to the top-level keys the buzz/laravel-h-captcha
// package reads internally.
$hcaptchaSecret = $request->input('captcha_hcaptcha_secret');
$hcaptchaSitekey = $request->input('captcha_hcaptcha_sitekey');
$putIfChanged('captcha.hcaptcha.secret', $hcaptchaSecret);
$putIfChanged('captcha.hcaptcha.sitekey', $hcaptchaSitekey);
$putIfChanged('captcha.secret', $hcaptchaSecret);
$putIfChanged('captcha.sitekey', $hcaptchaSitekey);
// Turnstile credentials (sitekey is public, store as-is when present)
$putIfChanged('captcha.turnstile.secret', $request->input('captcha_turnstile_secret'));
if ($request->filled('captcha_turnstile_sitekey')) {
ConfigCacheService::put('captcha.turnstile.sitekey', $request->input('captcha_turnstile_sitekey'));
} }
// Cap credentials (endpoint is public, store as-is when present)
$putIfChanged('captcha.cap.secret', $request->input('captcha_cap_secret'));
if ($request->filled('captcha_cap_endpoint')) {
ConfigCacheService::put('captcha.cap.endpoint', $request->input('captcha_cap_endpoint'));
}
ConfigCacheService::put('captcha.active.login', $request->boolean('captcha_on_login')); ConfigCacheService::put('captcha.active.login', $request->boolean('captcha_on_login'));
ConfigCacheService::put('captcha.active.register', $request->boolean('captcha_on_register')); ConfigCacheService::put('captcha.active.register', $request->boolean('captcha_on_register'));
ConfigCacheService::put('captcha.active.forgotpassword', $request->boolean('captcha_on_forgotpassword'));
ConfigCacheService::put('captcha.active.password_reset', $request->boolean('captcha_on_password_reset'));
ConfigCacheService::put('captcha.active.curated_register', $request->boolean('captcha_on_curated_register'));
ConfigCacheService::put('captcha.triggers.login.enabled', $request->boolean('captcha_on_login')); ConfigCacheService::put('captcha.triggers.login.enabled', $request->boolean('captcha_on_login'));
ConfigCacheService::put('captcha.enabled', true); ConfigCacheService::put('captcha.enabled', true);
} else { } else {
@ -720,10 +758,18 @@ trait AdminSettingsController
'allow_post_embeds' => $request->boolean('allow_post_embeds'), 'allow_post_embeds' => $request->boolean('allow_post_embeds'),
'allow_profile_embeds' => $request->boolean('allow_profile_embeds'), 'allow_profile_embeds' => $request->boolean('allow_profile_embeds'),
'captcha_enabled' => $request->boolean('captcha_enabled'), 'captcha_enabled' => $request->boolean('captcha_enabled'),
'captcha_driver' => $request->input('captcha_driver', 'hcaptcha'),
'captcha_on_login' => $request->boolean('captcha_on_login'), 'captcha_on_login' => $request->boolean('captcha_on_login'),
'captcha_on_register' => $request->boolean('captcha_on_register'), 'captcha_on_register' => $request->boolean('captcha_on_register'),
'captcha_secret' => $request->input('captcha_secret'), 'captcha_on_forgotpassword' => $request->boolean('captcha_on_forgotpassword'),
'captcha_sitekey' => $request->input('captcha_sitekey'), 'captcha_on_password_reset' => $request->boolean('captcha_on_password_reset'),
'captcha_on_curated_register' => $request->boolean('captcha_on_curated_register'),
'captcha_hcaptcha_secret' => $request->input('captcha_hcaptcha_secret'),
'captcha_hcaptcha_sitekey' => $request->input('captcha_hcaptcha_sitekey'),
'captcha_turnstile_secret' => $request->input('captcha_turnstile_secret'),
'captcha_turnstile_sitekey' => $request->input('captcha_turnstile_sitekey'),
'captcha_cap_endpoint' => $request->input('captcha_cap_endpoint'),
'captcha_cap_secret' => $request->input('captcha_cap_secret'),
'custom_emoji_enabled' => $request->boolean('custom_emoji_enabled'), 'custom_emoji_enabled' => $request->boolean('custom_emoji_enabled'),
]; ];
Cache::forget('api:v1:instance-data:rules'); Cache::forget('api:v1:instance-data:rules');

@ -80,8 +80,8 @@ class AppRegisterController extends Controller
'email' => 'required|email:rfc,dns,spoof,strict|unique:users,email|unique:app_registers,email', 'email' => 'required|email:rfc,dns,spoof,strict|unique:users,email|unique:app_registers,email',
]; ];
if ((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) { if (app('captcha.manager')->activeOn('register')) {
$rules['h-captcha-response'] = 'required|captcha'; $rules[app('captcha.manager')->active()->responseField()] = 'required|captcha_verify';
} }
$this->validate($request, $rules); $this->validate($request, $rules);
@ -188,8 +188,8 @@ class AppRegisterController extends Controller
'email' => 'required|email:rfc,dns,spoof,strict|unique:users,email|exists:app_registers,email', 'email' => 'required|email:rfc,dns,spoof,strict|unique:users,email|exists:app_registers,email',
]; ];
if ((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) { if (app('captcha.manager')->activeOn('register')) {
$rules['h-captcha-response'] = 'required|captcha'; $rules[app('captcha.manager')->active()->responseField()] = 'required|captcha_verify';
} }
$this->validate($request, $rules); $this->validate($request, $rules);

@ -60,20 +60,18 @@ class ForgotPasswordController extends Controller
usleep(random_int(100000, 3000000)); usleep(random_int(100000, 3000000));
if ((bool) config_cache('captcha.enabled')) { $rules = [
$rules = [ 'email' => 'required|email',
'email' => 'required|email', ];
'h-captcha-response' => 'required|captcha', $messages = [];
];
} else { if (app('captcha.manager')->activeOn('forgotpassword')) {
$rules = [ $captchaField = app('captcha.manager')->active()->responseField();
'email' => 'required|email', $rules[$captchaField] = 'required|captcha_verify';
]; $messages[$captchaField] = 'Failed to validate the captcha.';
} }
$request->validate($rules, [ $request->validate($rules, $messages);
'h-captcha-response' => 'Failed to validate the captcha.',
]);
} }
/** /**

@ -553,21 +553,10 @@ class LoginController extends Controller
$messages = []; $messages = [];
if ( if (app('captcha.manager')->activeOnLogin()) {
(bool) config_cache('captcha.enabled') && $field = app('captcha.manager')->active()->responseField();
(bool) config_cache('captcha.active.login') || $rules[$field] = 'required|filled|captcha_verify';
( $messages[$field.'.required'] = 'The captcha must be filled';
(bool) config_cache('captcha.triggers.login.enabled') &&
request()->session()->has('login_attempts') &&
request()->session()->get('login_attempts') >=
config('captcha.triggers.login.attempts')
)
) {
$rules['h-captcha-response'] =
'required|filled|captcha|min:5';
$messages['h-captcha-response.required'] =
'The captcha must be filled';
} }
$request->validate($rules, $messages); $request->validate($rules, $messages);

@ -98,8 +98,8 @@ class RegisterController extends Controller
'password' => 'required|string|min:'.config('pixelfed.min_password_length').'|confirmed', 'password' => 'required|string|min:'.config('pixelfed.min_password_length').'|confirmed',
]; ];
if ((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) { if (app('captcha.manager')->activeOn('register')) {
$rules['h-captcha-response'] = 'required|captcha'; $rules[app('captcha.manager')->active()->responseField()] = 'required|captcha_verify';
} }
return Validator::make($data, $rules); return Validator::make($data, $rules);

@ -55,20 +55,17 @@ class ResetPasswordController extends Controller
{ {
usleep(random_int(100000, 3000000)); usleep(random_int(100000, 3000000));
if ((bool) config_cache('captcha.enabled')) { $rules = [
return [
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', 'max:72', Rules\Password::defaults()],
'h-captcha-response' => ['required', 'filled', 'captcha'],
];
}
return [
'token' => 'required', 'token' => 'required',
'email' => 'required|email', 'email' => 'required|email',
'password' => ['required', 'confirmed', 'max:72', Rules\Password::defaults()], 'password' => ['required', 'confirmed', 'max:72', Rules\Password::defaults()],
]; ];
if (app('captcha.manager')->activeOn('password_reset')) {
$rules[app('captcha.manager')->active()->responseField()] = ['required', 'filled', 'captcha_verify'];
}
return $rules;
} }
/** /**
@ -76,11 +73,13 @@ class ResetPasswordController extends Controller
*/ */
protected function validationErrorMessages(): array protected function validationErrorMessages(): array
{ {
$field = app('captcha.manager')->active()->responseField();
return [ return [
'password.max' => 'Passwords should not exceed 72 characters.', 'password.max' => 'Passwords should not exceed 72 characters.',
'h-captcha-response.required' => 'Failed to validate the captcha.', $field.'.required' => 'Failed to validate the captcha.',
'h-captcha-response.filled' => 'Failed to validate the captcha.', $field.'.filled' => 'Failed to validate the captcha.',
'h-captcha-response.captcha' => 'Failed to validate the captcha.', $field.'.captcha_verify' => 'Failed to validate the captcha.',
]; ];
} }

@ -17,6 +17,17 @@ use Illuminate\Support\Str;
class CuratedRegisterController extends Controller class CuratedRegisterController extends Controller
{ {
/**
* Whether a captcha should be enforced on the curated registration flow.
*
* True when the curated-registration-specific flag is on, or when the
* platform captcha is enabled with its "curated_register" surface active.
*/
protected function curatedCaptchaEnabled(): bool
{
return app('captcha.manager')->activeOn('curated_register');
}
public function preCheck($allowWhenDisabled = false): void public function preCheck($allowWhenDisabled = false): void
{ {
if (! $allowWhenDisabled) { if (! $allowWhenDisabled) {
@ -69,9 +80,9 @@ class CuratedRegisterController extends Controller
); );
$crid = $request->session()->get('cur-reg-con.cr-id'); $crid = $request->session()->get('cur-reg-con.cr-id');
$arid = $request->session()->get('cur-reg-con.ac-id'); $arid = $request->session()->get('cur-reg-con.ac-id');
$showCaptcha = config('instance.curated_registration.captcha_enabled'); $showCaptcha = $this->curatedCaptchaEnabled();
if ($attempts = $request->session()->get('cur-reg-con-attempt')) { if ($attempts = $request->session()->get('cur-reg-con-attempt')) {
$showCaptcha = $attempts && $attempts >= 2; $showCaptcha = $showCaptcha && $attempts >= 2;
} else { } else {
$showCaptcha = false; $showCaptcha = false;
} }
@ -98,9 +109,10 @@ class CuratedRegisterController extends Controller
'crid' => 'required|integer|min:1', 'crid' => 'required|integer|min:1',
'acid' => 'required|integer|min:1', 'acid' => 'required|integer|min:1',
]; ];
if (config('instance.curated_registration.captcha_enabled') && $attempts >= 3) { if ($this->curatedCaptchaEnabled() && $attempts >= 3) {
$rules['h-captcha-response'] = 'required|captcha'; $captchaField = app('captcha.manager')->active()->responseField();
$messages['h-captcha-response.required'] = 'The captcha must be filled'; $rules[$captchaField] = 'required|captcha_verify';
$messages[$captchaField.'.required'] = 'The captcha must be filled';
} }
$this->validate($request, $rules, $messages); $this->validate($request, $rules, $messages);
$crid = $request->session()->get('cur-reg-con.cr-id'); $crid = $request->session()->get('cur-reg-con.cr-id');
@ -141,9 +153,10 @@ class CuratedRegisterController extends Controller
'response' => 'required_if:action,message|string|min:20|max:1000', 'response' => 'required_if:action,message|string|min:20|max:1000',
]; ];
$messages = []; $messages = [];
if (config('instance.curated_registration.captcha_enabled')) { if ($this->curatedCaptchaEnabled()) {
$rules['h-captcha-response'] = 'required|captcha'; $captchaField = app('captcha.manager')->active()->responseField();
$messages['h-captcha-response.required'] = 'The captcha must be filled'; $rules[$captchaField] = 'required|captcha_verify';
$messages[$captchaField.'.required'] = 'The captcha must be filled';
} }
$this->validate($request, $rules, $messages); $this->validate($request, $rules, $messages);
@ -219,9 +232,10 @@ class CuratedRegisterController extends Controller
$messages = []; $messages = [];
if (config('instance.curated_registration.captcha_enabled')) { if ($this->curatedCaptchaEnabled()) {
$rules['h-captcha-response'] = 'required|captcha'; $captchaField = app('captcha.manager')->active()->responseField();
$messages['h-captcha-response.required'] = 'The captcha must be filled'; $rules[$captchaField] = 'required|captcha_verify';
$messages[$captchaField.'.required'] = 'The captcha must be filled';
} }
$this->validate($request, $rules, $messages); $this->validate($request, $rules, $messages);
@ -279,9 +293,10 @@ class CuratedRegisterController extends Controller
'code' => 'required', 'code' => 'required',
]; ];
$messages = []; $messages = [];
if (config('instance.curated_registration.captcha_enabled')) { if ($this->curatedCaptchaEnabled()) {
$rules['h-captcha-response'] = 'required|captcha'; $captchaField = app('captcha.manager')->active()->responseField();
$messages['h-captcha-response.required'] = 'The captcha must be filled'; $rules[$captchaField] = 'required|captcha_verify';
$messages[$captchaField.'.required'] = 'The captcha must be filled';
} }
$this->validate($request, $rules, $messages); $this->validate($request, $rules, $messages);

@ -37,8 +37,9 @@ class UserEmailForgotController extends Controller
]; ];
if ((bool) config_cache('captcha.enabled')) { if ((bool) config_cache('captcha.enabled')) {
$rules['h-captcha-response'] = 'required|captcha'; $captchaField = app('captcha.manager')->active()->responseField();
$messages['h-captcha-response.required'] = 'You need to complete the captcha!'; $rules[$captchaField] = 'required|captcha_verify';
$messages[$captchaField.'.required'] = 'You need to complete the captcha!';
} }
$randomDelay = random_int(500000, 2000000); $randomDelay = random_int(500000, 2000000);

@ -0,0 +1,56 @@
<?php
namespace App\Providers;
use App\Services\Captcha\CaptchaManager;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\ServiceProvider;
class CaptchaServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton('captcha.manager', fn ($app) => new CaptchaManager($app));
$this->app->alias('captcha.manager', CaptchaManager::class);
}
public function boot(): void
{
$this->registerValidationRule();
$this->registerBladeDirectives();
}
/**
* Driver-agnostic validation rule.
*
* Usage: 'some_field' => 'captcha_verify'
*
* It ignores $value (each provider uses a different field name) and instead
* validates the whole request against the active driver's own response field.
*/
private function registerValidationRule(): void
{
Validator::extend('captcha_verify', function ($attribute, $value, $parameters, $validator) {
/** @var CaptchaManager $manager */
$manager = app('captcha.manager');
return $manager->active()->verify($validator->getData());
}, 'The captcha verification failed. Please try again.');
}
private function registerBladeDirectives(): void
{
// @captcha or @captcha(['data-theme' => 'dark']) -> renders active widget
Blade::directive('captcha', function ($expression) {
$args = trim((string) $expression) === '' ? '[]' : $expression;
return "<?php echo app('captcha.manager')->active()->render($args); ?>";
});
// @captchaScripts -> any <script>/<link> the active widget needs
Blade::directive('captchaScripts', function () {
return "<?php echo app('captcha.manager')->active()->scripts(); ?>";
});
}
}

@ -131,14 +131,38 @@ class AdminSettingsService
'allow_post_embeds' => (bool) config_cache('instance.embed.post'), 'allow_post_embeds' => (bool) config_cache('instance.embed.post'),
'allow_profile_embeds' => (bool) config_cache('instance.embed.profile'), 'allow_profile_embeds' => (bool) config_cache('instance.embed.profile'),
'captcha_enabled' => (bool) config_cache('captcha.enabled'), 'captcha_enabled' => (bool) config_cache('captcha.enabled'),
'captcha_driver' => config_cache('captcha.driver') ?: config('captcha.driver', 'hcaptcha'),
'captcha_on_login' => (bool) config_cache('captcha.active.login'), 'captcha_on_login' => (bool) config_cache('captcha.active.login'),
'captcha_on_register' => (bool) config_cache('captcha.active.register'), 'captcha_on_register' => (bool) config_cache('captcha.active.register'),
'captcha_secret' => Str::mask(config_cache('captcha.secret'), '*', 4, -4), 'captcha_on_forgotpassword' => (bool) config_cache('captcha.active.forgotpassword'),
'captcha_sitekey' => Str::mask(config_cache('captcha.sitekey'), '*', 4, -4), 'captcha_on_password_reset' => (bool) config_cache('captcha.active.password_reset'),
'captcha_on_curated_register' => (bool) config_cache('captcha.active.curated_register'),
'captcha_hcaptcha_secret' => self::maskSecret(config_cache('captcha.hcaptcha.secret')),
'captcha_hcaptcha_sitekey' => self::maskSecret(config_cache('captcha.hcaptcha.sitekey')),
'captcha_turnstile_secret' => self::maskSecret(config_cache('captcha.turnstile.secret')),
'captcha_turnstile_sitekey' => config_cache('captcha.turnstile.sitekey'),
'captcha_cap_endpoint' => config_cache('captcha.cap.endpoint'),
'captcha_cap_secret' => self::maskSecret(config_cache('captcha.cap.secret')),
'custom_emoji_enabled' => (bool) config_cache('federation.custom_emoji.enabled'), 'custom_emoji_enabled' => (bool) config_cache('federation.custom_emoji.enabled'),
]; ];
} }
/**
* Mask a secret value for display, tolerating null/empty values.
*/
protected static function maskSecret($value): ?string
{
if (empty($value)) {
return $value === null ? null : (string) $value;
}
if (strlen((string) $value) < 8) {
return str_repeat('*', strlen((string) $value));
}
return Str::mask((string) $value, '*', 4, -4);
}
public static function getStorage() public static function getStorage()
{ {
$cloud_storage = (bool) config_cache('pixelfed.cloud_storage'); $cloud_storage = (bool) config_cache('pixelfed.cloud_storage');
@ -179,7 +203,6 @@ class AdminSettingsService
$res = [ $res = [
'enabled' => (bool) config_cache('instance.curated_registration.enabled'), 'enabled' => (bool) config_cache('instance.curated_registration.enabled'),
'resend_confirmation_limit' => config_cache('instance.curated_registration.resend_confirmation_limit'), 'resend_confirmation_limit' => config_cache('instance.curated_registration.resend_confirmation_limit'),
'captcha_enabled' => config_cache('instance.curated_registration.captcha_enabled'),
'state' => config_cache('instance.curated_registration.state'), 'state' => config_cache('instance.curated_registration.state'),
'notify' => config_cache('instance.curated_registration.notify'), 'notify' => config_cache('instance.curated_registration.notify'),
]; ];

@ -0,0 +1,82 @@
<?php
namespace App\Services\Captcha;
use App\Contracts\CaptchaDriver;
use Illuminate\Http\Client\Factory as HttpFactory;
use LaravelCap\Cap;
/**
* Cap driver (self-hosted proof-of-work CAPTCHA).
*
* Wraps the oliweb/laravel-cap package for verification, and renders the
* locally-published widget (public/vendor/cap/) so no external CDN is used.
*
* @see https://github.com/oliweb-ch/laravel-cap
*/
class CapDriver implements CaptchaDriver
{
/**
* Default @cap.js/widget version served from the CDN. "latest" tracks the
* newest stable release; override via captcha.cap.widget_version.
*/
private const DEFAULT_WIDGET_VERSION = 'latest';
public function name(): string
{
return 'cap';
}
public function isConfigured(): bool
{
return ! empty(config_cache('captcha.cap.endpoint'))
&& ! empty(config_cache('captcha.cap.secret'));
}
public function responseField(): string
{
return (string) config('captcha.cap.token_field', 'cap-token');
}
public function verify(array $input): bool
{
$token = $input[$this->responseField()] ?? null;
if (empty($token)) {
return false;
}
$cap = new Cap(app(HttpFactory::class), [
'endpoint' => config_cache('captcha.cap.endpoint'),
'secret' => config_cache('captcha.cap.secret'),
'timeout' => (int) config('captcha.cap.timeout', 5),
'fail_open' => (bool) config('captcha.cap.fail_open', false),
]);
return $cap->verify((string) $token);
}
public function render(array $attributes = []): string
{
$endpoint = e((string) config_cache('captcha.cap.endpoint'));
$field = e($this->responseField());
$attrs = '';
foreach ($attributes as $key => $value) {
$attrs .= ' '.e($key).'="'.e($value).'"';
}
return '<cap-widget data-cap-api-endpoint="'.$endpoint.'"'
.' data-cap-hidden-field-name="'.$field.'"'.$attrs.'></cap-widget>';
}
public function scripts(): string
{
// Load the widget from the jsDelivr CDN. Defaults to the "latest"
// stable release; pin a specific version via captcha.cap.widget_version.
$version = trim((string) config('captcha.cap.widget_version')) ?: self::DEFAULT_WIDGET_VERSION;
$src = 'https://cdn.jsdelivr.net/npm/@cap.js/widget@'.$version;
return '<script src="'.e($src).'"></script>';
}
}

@ -0,0 +1,128 @@
<?php
namespace App\Services\Captcha;
use App\Contracts\CaptchaDriver;
use Illuminate\Support\Manager;
/**
* Resolves the active captcha provider based on the "captcha.driver" config
* value and proxies the provider-agnostic operations to it.
*
* @method string name()
* @method bool isConfigured()
* @method string responseField()
* @method bool verify(array $input)
* @method string render(array $attributes = [])
* @method string scripts()
*/
class CaptchaManager extends Manager
{
/**
* The default driver name, resolved from config. Falls back to hcaptcha to
* preserve existing behavior for instances that never set captcha.driver.
*/
public function getDefaultDriver(): string
{
return (string) (config_cache('captcha.driver') ?: config('captcha.driver', 'hcaptcha'));
}
public function createHcaptchaDriver(): CaptchaDriver
{
return new HCaptchaDriver;
}
public function createTurnstileDriver(): CaptchaDriver
{
return new TurnstileDriver;
}
public function createCapDriver(): CaptchaDriver
{
return new CapDriver;
}
/**
* The active driver instance.
*/
public function active(): CaptchaDriver
{
return $this->driver();
}
/**
* Whether captcha is globally enabled for this instance.
*/
public function enabled(): bool
{
return (bool) config_cache('captcha.enabled');
}
/**
* Whether captcha should be enforced on a given surface.
*
* Requires the global toggle plus the per-surface "active" flag. Supported
* surfaces: login, register, forgotpassword, password_reset,
* curated_register.
*/
public function activeOn(string $surface): bool
{
if (! $this->enabled()) {
return false;
}
return (bool) config_cache('captcha.active.'.$surface);
}
/**
* Whether the login form should show/enforce a captcha right now.
*
* True when the login surface is active, or when the failed-login trigger
* has reached its configured attempt threshold for the current session.
*/
public function activeOnLogin(): bool
{
if ($this->activeOn('login')) {
return true;
}
if (! (bool) config_cache('captcha.triggers.login.enabled')) {
return false;
}
$request = request();
if (! $request->hasSession()) {
return false;
}
$session = $request->session();
return $session->has('login_attempts')
&& $session->get('login_attempts') >= config('captcha.triggers.login.attempts');
}
/**
* List of supported driver machine names.
*
* @return array<int, string>
*/
public function available(): array
{
return ['hcaptcha', 'turnstile', 'cap'];
}
/**
* Validation rules for the active driver, keyed by its response field.
*
* Merge the result into a controller's rule set to enforce captcha with
* whatever provider is currently selected.
*
* @return array<string, string>
*/
public function rules(): array
{
return [
$this->active()->responseField() => 'required|captcha_verify',
];
}
}

@ -0,0 +1,63 @@
<?php
namespace App\Services\Captcha;
use App\Contracts\CaptchaDriver;
use Illuminate\Support\Facades\Validator;
/**
* hCaptcha driver. Wraps the existing buzz/laravel-h-captcha package so behavior
* is identical to the previous hardcoded integration.
*/
class HCaptchaDriver implements CaptchaDriver
{
public function name(): string
{
return 'hcaptcha';
}
public function isConfigured(): bool
{
$secret = config_cache('captcha.hcaptcha.secret');
$sitekey = config_cache('captcha.hcaptcha.sitekey');
return ! empty($secret)
&& ! empty($sitekey)
&& $secret !== 'default_secret'
&& $sitekey !== 'default_sitekey';
}
public function responseField(): string
{
return 'h-captcha-response';
}
public function verify(array $input): bool
{
$token = $input[$this->responseField()] ?? null;
if (empty($token)) {
return false;
}
// Reuse the package's registered "captcha" validation rule so we get the
// exact same server-side verification as before.
return Validator::make(
[$this->responseField() => $token],
[$this->responseField() => 'required|captcha']
)->passes();
}
public function render(array $attributes = []): string
{
// Resolve the buzz/laravel-h-captcha service (bound as "captcha").
// display() already emits the widget script tag inline.
return app('captcha')->display($attributes);
}
public function scripts(): string
{
// The hCaptcha widget script is injected by display() output/config.
return '';
}
}

@ -0,0 +1,80 @@
<?php
namespace App\Services\Captcha;
use App\Contracts\CaptchaDriver;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Cloudflare Turnstile driver.
*
* @see https://developers.cloudflare.com/turnstile/
*/
class TurnstileDriver implements CaptchaDriver
{
private const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
private const SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
public function name(): string
{
return 'turnstile';
}
public function isConfigured(): bool
{
return ! empty(config_cache('captcha.turnstile.secret'))
&& ! empty(config_cache('captcha.turnstile.sitekey'));
}
public function responseField(): string
{
return 'cf-turnstile-response';
}
public function verify(array $input): bool
{
$token = $input[$this->responseField()] ?? null;
if (empty($token)) {
return false;
}
try {
$response = Http::asForm()
->timeout((int) config('captcha.turnstile.timeout', 5))
->post(self::VERIFY_URL, [
'secret' => config_cache('captcha.turnstile.secret'),
'response' => $token,
]);
} catch (\Throwable $e) {
Log::warning('[captcha:turnstile] verify request failed: '.$e->getMessage());
return (bool) config('captcha.turnstile.fail_open', false);
}
if ($response->failed()) {
return (bool) config('captcha.turnstile.fail_open', false);
}
return (bool) $response->json('success', false);
}
public function render(array $attributes = []): string
{
$sitekey = e((string) config_cache('captcha.turnstile.sitekey'));
$attrs = '';
foreach ($attributes as $key => $value) {
$attrs .= ' '.e($key).'="'.e($value).'"';
}
return '<div class="cf-turnstile" data-sitekey="'.$sitekey.'"'.$attrs.'></div>';
}
public function scripts(): string
{
return '<script src="'.self::SCRIPT_URL.'" async defer></script>';
}
}

@ -16,8 +16,12 @@ class ConfigCacheService
'filesystems.disks.s3.secret', 'filesystems.disks.s3.secret',
'filesystems.disks.spaces.key', 'filesystems.disks.spaces.key',
'filesystems.disks.spaces.secret', 'filesystems.disks.spaces.secret',
'captcha.hcaptcha.secret',
'captcha.hcaptcha.sitekey',
'captcha.secret', 'captcha.secret',
'captcha.sitekey', 'captcha.sitekey',
'captcha.turnstile.secret',
'captcha.cap.secret',
]; ];
public static function get($key) public static function get($key)
@ -103,10 +107,20 @@ class ConfigCacheService
'instance.embed.post', 'instance.embed.post',
'captcha.enabled', 'captcha.enabled',
'captcha.driver',
'captcha.hcaptcha.secret',
'captcha.hcaptcha.sitekey',
'captcha.secret', 'captcha.secret',
'captcha.sitekey', 'captcha.sitekey',
'captcha.turnstile.secret',
'captcha.turnstile.sitekey',
'captcha.cap.endpoint',
'captcha.cap.secret',
'captcha.active.login', 'captcha.active.login',
'captcha.active.register', 'captcha.active.register',
'captcha.active.forgotpassword',
'captcha.active.password_reset',
'captcha.active.curated_register',
'captcha.triggers.login.enabled', 'captcha.triggers.login.enabled',
'captcha.triggers.login.attempts', 'captcha.triggers.login.attempts',
'federation.custom_emoji.enabled', 'federation.custom_emoji.enabled',

@ -1,11 +1,13 @@
<?php <?php
use App\Providers\AppServiceProvider; use App\Providers\AppServiceProvider;
use App\Providers\CaptchaServiceProvider;
use App\Providers\HorizonServiceProvider; use App\Providers\HorizonServiceProvider;
use App\Providers\PassportServiceProvider; use App\Providers\PassportServiceProvider;
return [ return [
AppServiceProvider::class, AppServiceProvider::class,
CaptchaServiceProvider::class,
HorizonServiceProvider::class, HorizonServiceProvider::class,
PassportServiceProvider::class, PassportServiceProvider::class,
]; ];

@ -34,6 +34,7 @@
"league/oauth2-client": "^2.8", "league/oauth2-client": "^2.8",
"league/uri": "^7.4", "league/uri": "^7.4",
"matomo/device-detector": "^6.5", "matomo/device-detector": "^6.5",
"oliweb/laravel-cap": "^1.10",
"pbmedia/laravel-ffmpeg": "^8.0", "pbmedia/laravel-ffmpeg": "^8.0",
"phpseclib/phpseclib": "~3.0", "phpseclib/phpseclib": "~3.0",
"pixelfed/fractal": "^0.18.0", "pixelfed/fractal": "^0.18.0",

65
composer.lock generated

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "f850111c7826d7c8383045e89f52f480", "content-hash": "e627796e7ccac95139ad365d270f2f36",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@ -4845,6 +4845,69 @@
], ],
"time": "2026-02-16T23:10:27+00:00" "time": "2026-02-16T23:10:27+00:00"
}, },
{
"name": "oliweb/laravel-cap",
"version": "v1.10.0",
"source": {
"type": "git",
"url": "https://github.com/oliweb-ch/laravel-cap.git",
"reference": "6ef553ba99d9e6c27b7195ec5549cc12f006a876"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/oliweb-ch/laravel-cap/zipball/6ef553ba99d9e6c27b7195ec5549cc12f006a876",
"reference": "6ef553ba99d9e6c27b7195ec5549cc12f006a876",
"shasum": ""
},
"require": {
"illuminate/http": "^12.0|^13.0",
"illuminate/support": "^12.0|^13.0",
"php": "^8.2"
},
"require-dev": {
"orchestra/testbench": "^10.0|^11.0",
"phpunit/phpunit": "^11.0|^12.0"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Cap": "LaravelCap\\Facades\\Cap"
},
"providers": [
"LaravelCap\\CapServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"LaravelCap\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "oliweb",
"homepage": "https://github.com/oli217"
}
],
"description": "Laravel wrapper for Cap (tiagozip/cap) — self-hosted CAPTCHA alternative",
"keywords": [
"cap",
"captcha",
"laravel",
"proof-of-work",
"spam-protection"
],
"support": {
"issues": "https://github.com/oliweb-ch/laravel-cap/issues",
"source": "https://github.com/oliweb-ch/laravel-cap/tree/v1.10.0"
},
"time": "2026-08-09T14:23:54+00:00"
},
{ {
"name": "paragonie/constant_time_encoding", "name": "paragonie/constant_time_encoding",
"version": "v3.1.3", "version": "v3.1.3",

@ -0,0 +1,74 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cap instance endpoint
|--------------------------------------------------------------------------
|
| The full URL of your self-hosted Cap instance, including the site key.
| Example: https://cap.example.com/your-site-key/
|
*/
'endpoint' => env('CAP_ENDPOINT'),
/*
|--------------------------------------------------------------------------
| Secret key
|--------------------------------------------------------------------------
|
| The secret key generated in your Cap dashboard.
| Never expose this value on the client side.
|
*/
'secret' => env('CAP_SECRET'),
/*
|--------------------------------------------------------------------------
| Token field name
|--------------------------------------------------------------------------
|
| The name of the hidden field automatically injected by the Cap widget
| into the parent form. Can be overridden via the data-cap-hidden-field-name
| attribute.
|
*/
'token_field' => env('CAP_TOKEN_FIELD', 'cap-token'),
/*
|--------------------------------------------------------------------------
| Verification timeout
|--------------------------------------------------------------------------
|
| Time (in seconds) before giving up on the request to /siteverify.
|
*/
'timeout' => (int) env('CAP_TIMEOUT', 5),
/*
|--------------------------------------------------------------------------
| Fail-open mode
|--------------------------------------------------------------------------
|
| When true, any communication error with the Cap instance (network,
| timeout, 5xx server error) lets the request through instead of blocking
| it. An explicitly invalid token (success: false) is always rejected,
| regardless of this setting.
|
| Recommended in production when service availability matters more than
| anti-spam protection.
|
*/
'fail_open' => (bool) env('CAP_FAIL_OPEN', false),
/*
|--------------------------------------------------------------------------
| Iframe widget route
|--------------------------------------------------------------------------
|
| Path of the route serving the Cap widget in iframe mode (permissive CSP).
| Use @capFrame in your Blade templates to embed this mode.
|
*/
'frame_route' => env('CAP_FRAME_ROUTE', 'cap-frame'),
];

@ -3,7 +3,39 @@
use Buzz\LaravelHCaptcha\HttpClient; use Buzz\LaravelHCaptcha\HttpClient;
return [ return [
/*
|--------------------------------------------------------------------------
| Global toggle
|--------------------------------------------------------------------------
| Whether any captcha is enabled at all. Kept for backward compatibility.
*/
'enabled' => env('CAPTCHA_ENABLED', false), 'enabled' => env('CAPTCHA_ENABLED', false),
/*
|--------------------------------------------------------------------------
| Active driver
|--------------------------------------------------------------------------
| Which provider to use: "hcaptcha", "turnstile", or "cap". Admin-selectable.
| Defaults to hcaptcha so existing instances keep their current behavior.
*/
'driver' => env('CAPTCHA_DRIVER', 'hcaptcha'),
/*
|--------------------------------------------------------------------------
| hCaptcha
|--------------------------------------------------------------------------
| Canonical, provider-namespaced credentials used throughout the app.
*/
'hcaptcha' => [
'secret' => env('CAPTCHA_SECRET', 'default_secret'),
'sitekey' => env('CAPTCHA_SITEKEY', 'default_sitekey'),
],
/*
| The top-level secret/sitekey keys below are what the buzz/laravel-h-captcha
| package reads internally (config('captcha.secret') / config('captcha.sitekey')).
| They mirror captcha.hcaptcha.* and are kept in sync when settings are saved.
*/
'secret' => env('CAPTCHA_SECRET', 'default_secret'), 'secret' => env('CAPTCHA_SECRET', 'default_secret'),
'sitekey' => env('CAPTCHA_SITEKEY', 'default_sitekey'), 'sitekey' => env('CAPTCHA_SITEKEY', 'default_sitekey'),
'http_client' => HttpClient::class, 'http_client' => HttpClient::class,
@ -15,11 +47,53 @@ return [
'theme' => 'light', 'theme' => 'light',
], ],
/*
|--------------------------------------------------------------------------
| Cloudflare Turnstile
|--------------------------------------------------------------------------
*/
'turnstile' => [
'sitekey' => env('CAPTCHA_TURNSTILE_SITEKEY'),
'secret' => env('CAPTCHA_TURNSTILE_SECRET'),
'timeout' => (int) env('CAPTCHA_TURNSTILE_TIMEOUT', 5),
'fail_open' => (bool) env('CAPTCHA_TURNSTILE_FAIL_OPEN', false),
],
/*
|--------------------------------------------------------------------------
| Cap (self-hosted proof-of-work CAPTCHA)
|--------------------------------------------------------------------------
| The endpoint must include the site key and a trailing slash, e.g.
| https://cap.example.com/your-site-key/
*/
'cap' => [
'endpoint' => env('CAP_ENDPOINT'),
'secret' => env('CAP_SECRET'),
'token_field' => env('CAP_TOKEN_FIELD', 'cap-token'),
'timeout' => (int) env('CAP_TIMEOUT', 5),
'fail_open' => (bool) env('CAP_FAIL_OPEN', false),
'widget_version' => env('CAP_WIDGET_VERSION') ?: 'latest',
],
/*
|--------------------------------------------------------------------------
| Where captcha is active
|--------------------------------------------------------------------------
| Per-surface toggles. Each requires the global "enabled" flag to also be on.
*/
'active' => [ 'active' => [
'login' => env('CAPTCHA_ENABLED_ON_LOGIN', false), 'login' => env('CAPTCHA_ENABLED_ON_LOGIN', false),
'register' => env('CAPTCHA_ENABLED_ON_REGISTER', false), 'register' => env('CAPTCHA_ENABLED_ON_REGISTER', false),
'forgotpassword' => env('CAPTCHA_ENABLED_ON_FORGOT_PASSWORD', false),
'password_reset' => env('CAPTCHA_ENABLED_ON_PASSWORD_RESET', false),
'curated_register' => env('CAPTCHA_ENABLED_ON_CURATED_REGISTER', false),
], ],
/*
|--------------------------------------------------------------------------
| Login-attempt triggers
|--------------------------------------------------------------------------
*/
'triggers' => [ 'triggers' => [
'login' => [ 'login' => [
'enabled' => env('CAPTCHA_TRIGGERS_LOGIN_ENABLED', false), 'enabled' => env('CAPTCHA_TRIGGERS_LOGIN_ENABLED', false),

@ -151,8 +151,6 @@ return [
'resend_confirmation_limit' => env('INSTANCE_CUR_REG_RESEND_LIMIT', 5), 'resend_confirmation_limit' => env('INSTANCE_CUR_REG_RESEND_LIMIT', 5),
'captcha_enabled' => env('INSTANCE_CUR_REG_CAPTCHA', env('CAPTCHA_ENABLED', false)),
'state' => [ 'state' => [
'fallback_on_closed_reg' => true, 'fallback_on_closed_reg' => true,
'only_enabled_on_closed_reg' => env('INSTANCE_CUR_REG_STATE_ONLY_ON_CLOSED', true), 'only_enabled_on_closed_reg' => env('INSTANCE_CUR_REG_STATE_ONLY_ON_CLOSED', true),

@ -389,20 +389,37 @@
class="custom-control-input" class="custom-control-input"
id="hcp" id="hcp"
v-model="platform.captcha_enabled"> v-model="platform.captcha_enabled">
<label class="custom-control-label font-weight-bold" for="hcp">Enable hCaptcha</label> <label class="custom-control-label font-weight-bold" for="hcp">Enable Captcha</label>
</div> </div>
</div> </div>
<template v-if="platform.captcha_enabled"> <template v-if="platform.captcha_enabled">
<hr class="my-2"> <hr class="my-2">
<div class="row"> <div class="row">
<div class="col-12">
<div class="form-group my-1">
<label class="text-muted small font-weight-bold">Captcha Provider</label>
<select
class="form-control"
name="captcha_driver"
v-model="platform.captcha_driver">
<option value="hcaptcha">hCaptcha</option>
<option value="turnstile">Cloudflare Turnstile</option>
<option value="cap">Cap</option>
</select>
</div>
</div>
</div>
<!-- hCaptcha credentials -->
<div class="row" v-if="platform.captcha_driver === 'hcaptcha'">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<div class="form-group my-1"> <div class="form-group my-1">
<label class="text-muted small">hCaptcha Secret</label> <label class="text-muted small">hCaptcha Secret</label>
<input <input
type="text" type="text"
class="form-control" class="form-control"
name="captcha_secret" name="captcha_hcaptcha_secret"
v-model="platform.captcha_secret"> v-model="platform.captcha_hcaptcha_secret">
</div> </div>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
@ -411,8 +428,57 @@
<input <input
type="text" type="text"
class="form-control" class="form-control"
name="captcha_sitekey" name="captcha_hcaptcha_sitekey"
v-model="platform.captcha_sitekey"> v-model="platform.captcha_hcaptcha_sitekey">
</div>
</div>
</div>
<!-- Turnstile credentials -->
<div class="row" v-else-if="platform.captcha_driver === 'turnstile'">
<div class="col-12 col-md-6">
<div class="form-group my-1">
<label class="text-muted small">Turnstile Secret</label>
<input
type="text"
class="form-control"
name="captcha_turnstile_secret"
v-model="platform.captcha_turnstile_secret">
</div>
</div>
<div class="col-12 col-md-6">
<div class="form-group my-1">
<label class="text-muted small">Turnstile Sitekey</label>
<input
type="text"
class="form-control"
name="captcha_turnstile_sitekey"
v-model="platform.captcha_turnstile_sitekey">
</div>
</div>
</div>
<!-- Cap credentials -->
<div class="row" v-else-if="platform.captcha_driver === 'cap'">
<div class="col-12 col-md-6">
<div class="form-group my-1">
<label class="text-muted small">Cap Endpoint</label>
<input
type="text"
class="form-control"
name="captcha_cap_endpoint"
placeholder="https://cap.example.com/your-site-key/"
v-model="platform.captcha_cap_endpoint">
</div>
</div>
<div class="col-12 col-md-6">
<div class="form-group my-1">
<label class="text-muted small">Cap Secret</label>
<input
type="text"
class="form-control"
name="captcha_cap_secret"
v-model="platform.captcha_cap_secret">
</div> </div>
</div> </div>
</div> </div>
@ -440,11 +506,44 @@
<label class="custom-control-label font-weight-bold" for="captcha_on_register">Register Captcha</label> <label class="custom-control-label font-weight-bold" for="captcha_on_register">Register Captcha</label>
</div> </div>
</div> </div>
<div class="col-12 col-lg-6">
<div class="custom-control custom-checkbox">
<input
type="checkbox"
name="captcha_on_forgotpassword"
class="custom-control-input"
id="captcha_on_forgotpassword"
v-model="platform.captcha_on_forgotpassword">
<label class="custom-control-label font-weight-bold" for="captcha_on_forgotpassword">Forgot Password Captcha</label>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="custom-control custom-checkbox">
<input
type="checkbox"
name="captcha_on_password_reset"
class="custom-control-input"
id="captcha_on_password_reset"
v-model="platform.captcha_on_password_reset">
<label class="custom-control-label font-weight-bold" for="captcha_on_password_reset">Password Reset Captcha</label>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="custom-control custom-checkbox">
<input
type="checkbox"
name="captcha_on_curated_register"
class="custom-control-input"
id="captcha_on_curated_register"
v-model="platform.captcha_on_curated_register">
<label class="custom-control-label font-weight-bold" for="captcha_on_curated_register">Curated Register Captcha</label>
</div>
</div>
</div> </div>
<hr class="mt-4 mb-2"> <hr class="mt-4 mb-2">
</template> </template>
<p class="help-text small text-muted mb-0"> <p class="help-text small text-muted mb-0">
Enable hCaptcha on login and register pages Enable a captcha provider on login and register pages
</p> </p>
</div> </div>
@ -1391,10 +1490,18 @@
allow_post_embeds: this.platform.allow_post_embeds, allow_post_embeds: this.platform.allow_post_embeds,
allow_profile_embeds: this.platform.allow_profile_embeds, allow_profile_embeds: this.platform.allow_profile_embeds,
captcha_enabled: this.platform.captcha_enabled, captcha_enabled: this.platform.captcha_enabled,
captcha_secret: this.platform.captcha_secret, captcha_driver: this.platform.captcha_driver,
captcha_sitekey: this.platform.captcha_sitekey, captcha_hcaptcha_secret: this.platform.captcha_hcaptcha_secret,
captcha_hcaptcha_sitekey: this.platform.captcha_hcaptcha_sitekey,
captcha_turnstile_secret: this.platform.captcha_turnstile_secret,
captcha_turnstile_sitekey: this.platform.captcha_turnstile_sitekey,
captcha_cap_endpoint: this.platform.captcha_cap_endpoint,
captcha_cap_secret: this.platform.captcha_cap_secret,
captcha_on_login: this.platform.captcha_on_login, captcha_on_login: this.platform.captcha_on_login,
captcha_on_register: this.platform.captcha_on_register, captcha_on_register: this.platform.captcha_on_register,
captcha_on_forgotpassword: this.platform.captcha_on_forgotpassword,
captcha_on_password_reset: this.platform.captcha_on_password_reset,
captcha_on_curated_register: this.platform.captcha_on_curated_register,
custom_emoji_enabled: this.platform.custom_emoji_enabled, custom_emoji_enabled: this.platform.custom_emoji_enabled,
}).then(res => { }).then(res => {
this.platform = res.data; this.platform = res.data;

@ -47,11 +47,7 @@
<span id="charCount" class="text-white">0</span>/<span>1000</span> <span id="charCount" class="text-white">0</span>/<span>1000</span>
</div> </div>
</div> </div>
@if($showCaptcha) <x-captcha :show="$showCaptcha" />
<div class="d-flex justify-content-center my-3">
{!! Captcha::display() !!}
</div>
@endif
<div class="text-center"> <div class="text-center">
<button class="btn btn-primary font-weight-bold rounded-pill px-5">Submit my response</button> <button class="btn btn-primary font-weight-bold rounded-pill px-5">Submit my response</button>
</div> </div>

@ -27,11 +27,7 @@
@csrf @csrf
<input type="hidden" name="sid" value="{{request()->input('sid')}}"> <input type="hidden" name="sid" value="{{request()->input('sid')}}">
<input type="hidden" name="code" value="{{request()->input('code')}}"> <input type="hidden" name="code" value="{{request()->input('code')}}">
@if(config('instance.curated_registration.captcha_enabled')) <x-captcha surface="curated_register" />
<div class="d-flex justify-content-center my-3">
{!! Captcha::display() !!}
</div>
@endif
<div class="mt-3 pt-4"> <div class="mt-3 pt-4">
<button class="btn btn-primary rounded-pill font-weight-bold btn-block">Confirm Email Address</button> <button class="btn btn-primary rounded-pill font-weight-bold btn-block">Confirm Email Address</button>
</div> </div>

@ -10,11 +10,7 @@
placeholder="Your email address" placeholder="Your email address"
required /> required />
</div> </div>
@if(config('instance.curated_registration.captcha_enabled')) <x-captcha surface="curated_register" />
<div class="d-flex justify-content-center my-3">
{!! Captcha::display() !!}
</div>
@endif
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
<button class="btn btn-primary font-weight-bold rounded-pill px-5">Verify</button> <button class="btn btn-primary font-weight-bold rounded-pill px-5">Verify</button>
</div> </div>

@ -18,11 +18,7 @@
value="{{ request()->session()->get('cur-reg.form-email') }}" value="{{ request()->session()->get('cur-reg.form-email') }}"
required> required>
</div> </div>
@if(config('instance.curated_registration.captcha_enabled')) <x-captcha surface="curated_register" />
<div class="d-flex justify-content-center my-3">
{!! Captcha::display() !!}
</div>
@endif
<div class="mt-3 pt-4"> <div class="mt-3 pt-4">
<button class="btn btn-primary rounded-pill font-weight-bold btn-block">My email is correct</button> <button class="btn btn-primary rounded-pill font-weight-bold btn-block">My email is correct</button>
</div> </div>

@ -33,11 +33,7 @@
placeholder="Your email address" placeholder="Your email address"
required /> required />
</div> </div>
@if(config('instance.curated_registration.captcha_enabled')) <x-captcha surface="curated_register" />
<div class="d-flex justify-content-center my-3">
{!! Captcha::display() !!}
</div>
@endif
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
<button class="btn btn-primary font-weight-bold rounded-pill px-5">Verify</button> <button class="btn btn-primary font-weight-bold rounded-pill px-5">Verify</button>
</div> </div>

@ -65,17 +65,7 @@
</div> </div>
</div> </div>
@if((bool) config_cache('captcha.enabled')) <x-captcha :show="\App\Facades\Captcha::enabled()" theme="dark" :label="true" :show-error="true" wrapperClass="d-flex flex-grow-1" />
<label class="font-weight-bold small text-muted">Captcha</label>
<div class="d-flex flex-grow-1">
{!! Captcha::display(['data-theme' => 'dark']) !!}
</div>
@if ($errors->has('h-captcha-response'))
<div class="text-danger small mb-3">
<strong>{{ $errors->first('h-captcha-response') }}</strong>
</div>
@endif
@endif
<div class="form-group row pt-4 mb-0"> <div class="form-group row pt-4 mb-0">
<div class="col-md-12"> <div class="col-md-12">

@ -35,11 +35,7 @@
@enderror @enderror
</div> </div>
@if((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) <x-captcha surface="register" wrapperClass="form-group text-center" />
<div class="form-group text-center">
{!! Captcha::display() !!}
</div>
@endif
<button type="submit" class="btn btn-primary btn-block"> <button type="submit" class="btn btn-primary btn-block">
Send Verification Code Send Verification Code

@ -32,11 +32,7 @@
@enderror @enderror
</div> </div>
@if((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) <x-captcha surface="register" wrapperClass="form-group text-center" />
<div class="form-group text-center">
{!! Captcha::display() !!}
</div>
@endif
<button type="submit" class="btn btn-primary btn-block"> <button type="submit" class="btn btn-primary btn-block">
Send Verification Code Send Verification Code

@ -219,19 +219,7 @@
</div> </div>
</div> </div>
@if( <x-captcha :show="\App\Facades\Captcha::activeOnLogin()" wrapperClass="d-flex justify-content-center mb-3" />
(bool) config_cache('captcha.enabled') &&
(bool) config_cache('captcha.active.login') ||
(
(bool) config_cache('captcha.triggers.login.enabled') &&
request()->session()->has('login_attempts') &&
request()->session()->get('login_attempts') >= config('captcha.triggers.login.attempts')
)
)
<div class="d-flex justify-content-center mb-3">
{!! Captcha::display() !!}
</div>
@endif
<button type="submit" class="btn btn-primary btn-block btn-lg font-weight-bold rounded-pill"> <button type="submit" class="btn btn-primary btn-block btn-lg font-weight-bold rounded-pill">
{{ __('auth.login') }} {{ __('auth.login') }}

@ -54,17 +54,7 @@
</div> </div>
</div> </div>
@if((bool) config_cache('captcha.enabled')) <x-captcha surface="forgotpassword" theme="dark" :label="true" :show-error="true" wrapperClass="d-flex flex-grow-1" />
<label class="font-weight-bold small text-muted">Captcha</label>
<div class="d-flex flex-grow-1">
{!! Captcha::display(['data-theme' => 'dark']) !!}
</div>
@if ($errors->has('h-captcha-response'))
<div class="text-danger small mb-3">
<strong>{{ $errors->first('h-captcha-response') }}</strong>
</div>
@endif
@endif
<div class="form-group row pt-4 mb-0"> <div class="form-group row pt-4 mb-0">
<div class="col-md-12"> <div class="col-md-12">

@ -109,17 +109,7 @@
</div> </div>
</div> </div>
@if((bool) config_cache('captcha.enabled')) <x-captcha surface="password_reset" theme="dark" :label="true" labelClass="font-weight-bold small pt-3 text-muted" :show-error="true" wrapperClass="d-flex flex-grow-1" />
<label class="font-weight-bold small pt-3 text-muted">Captcha</label>
<div class="d-flex flex-grow-1">
{!! Captcha::display(['data-theme' => 'dark']) !!}
</div>
@if ($errors->has('h-captcha-response'))
<div class="text-danger small mb-3">
<strong>{{ $errors->first('h-captcha-response') }}</strong>
</div>
@endif
@endif
<div class="form-group row pt-4 mb-0"> <div class="form-group row pt-4 mb-0">
<div class="col-md-12"> <div class="col-md-12">

@ -81,11 +81,7 @@
</div> </div>
</div> </div>
@if((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) <x-captcha surface="register" />
<div class="d-flex justify-content-center my-3">
{!! Captcha::display() !!}
</div>
@endif
<p class="small">{!! __('auth.terms') !!}</p> <p class="small">{!! __('auth.terms') !!}</p>

@ -0,0 +1,41 @@
@props([
/* Surface name to gate on (login, register, forgotpassword, password_reset,
curated_register). Ignored when :show is passed explicitly. */
'surface' => null,
/* Explicit visibility override for compound conditions (login triggers,
curated registration). When null, visibility is derived from $surface. */
'show' => null,
/* Optional widget theme, e.g. "dark". */
'theme' => null,
/* Render a "Captcha" label above the widget. */
'label' => false,
/* Classes for the label element. */
'labelClass' => 'font-weight-bold small text-muted',
/* Render the validation error message below the widget. */
'showError' => false,
/* Wrapper element classes around the widget itself. */
'wrapperClass' => 'd-flex justify-content-center my-3',
])
@php
$visible = ! is_null($show)
? (bool) $show
: ($surface ? \App\Facades\Captcha::activeOn($surface) : false);
$captchaAttrs = $theme ? ['data-theme' => $theme] : [];
$captchaField = \App\Facades\Captcha::active()->responseField();
@endphp
@if($visible)
@if($label)
<label class="{{ $labelClass }}">Captcha</label>
@endif
<div class="{{ $wrapperClass }}">
@captcha($captchaAttrs)
@captchaScripts
</div>
@if($showError && $errors->has($captchaField))
<div class="text-danger small mb-3">
<strong>{{ $errors->first($captchaField) }}</strong>
</div>
@endif
@endif

@ -90,11 +90,7 @@
</div> </div>
</div> </div>
@if((bool) config_cache('captcha.enabled')) <x-captcha surface="register" />
<div class="d-flex justify-content-center my-3">
{!! Captcha::display() !!}
</div>
@endif
<p class="small">By signing up, you agree to our <a href="{{route('site.terms')}}" class="font-weight-bold text-dark">Terms of Use</a> and <a href="{{route('site.privacy')}}" class="font-weight-bold text-dark">Privacy Policy</a>, in addition, you understand that your account is managed by <span class="font-weight-bold">{{ $pc->parent->username }}</span> and they can limit your account without your permission. For more details, view the <a href="/site/kb/parental-controls" class="text-dark font-weight-bold">Parental Controls</a> help center page.</p> <p class="small">By signing up, you agree to our <a href="{{route('site.terms')}}" class="font-weight-bold text-dark">Terms of Use</a> and <a href="{{route('site.privacy')}}" class="font-weight-bold text-dark">Privacy Policy</a>, in addition, you understand that your account is managed by <span class="font-weight-bold">{{ $pc->parent->username }}</span> and they can limit your account without your permission. For more details, view the <a href="/site/kb/parental-controls" class="text-dark font-weight-bold">Parental Controls</a> help center page.</p>

@ -0,0 +1,193 @@
<?php
namespace Tests\Feature;
use App\Services\Captcha\CapDriver;
use App\Services\Captcha\CaptchaManager;
use App\Services\Captcha\HCaptchaDriver;
use App\Services\Captcha\TurnstileDriver;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class CaptchaManagerTest extends TestCase
{
private function manager(): CaptchaManager
{
// Build a fresh manager so it re-reads the current config each time
// (the base Manager caches resolved drivers internally).
return new CaptchaManager($this->app);
}
#[Test]
public function it_resolves_the_hcaptcha_driver_by_default(): void
{
config(['captcha.driver' => 'hcaptcha']);
$driver = $this->manager()->active();
$this->assertInstanceOf(HCaptchaDriver::class, $driver);
$this->assertSame('hcaptcha', $driver->name());
$this->assertSame('h-captcha-response', $driver->responseField());
}
#[Test]
public function hcaptcha_is_configured_from_namespaced_keys(): void
{
config([
'captcha.driver' => 'hcaptcha',
'captcha.hcaptcha.secret' => 'a-real-secret',
'captcha.hcaptcha.sitekey' => 'a-real-sitekey',
]);
$this->assertTrue($this->manager()->active()->isConfigured());
// Placeholder defaults should read as not configured.
config([
'captcha.hcaptcha.secret' => 'default_secret',
'captcha.hcaptcha.sitekey' => 'default_sitekey',
]);
$this->assertFalse($this->manager()->active()->isConfigured());
}
#[Test]
public function it_resolves_the_turnstile_driver(): void
{
config(['captcha.driver' => 'turnstile']);
$driver = $this->manager()->active();
$this->assertInstanceOf(TurnstileDriver::class, $driver);
$this->assertSame('turnstile', $driver->name());
$this->assertSame('cf-turnstile-response', $driver->responseField());
}
#[Test]
public function it_resolves_the_cap_driver(): void
{
config(['captcha.driver' => 'cap']);
$driver = $this->manager()->active();
$this->assertInstanceOf(CapDriver::class, $driver);
$this->assertSame('cap', $driver->name());
$this->assertSame('cap-token', $driver->responseField());
}
#[Test]
public function it_lists_available_drivers(): void
{
$this->assertSame(['hcaptcha', 'turnstile', 'cap'], $this->manager()->available());
}
#[Test]
public function active_on_requires_the_global_toggle(): void
{
config([
'captcha.enabled' => false,
'captcha.active.login' => true,
]);
$this->assertFalse($this->manager()->activeOn('login'));
}
#[Test]
public function active_on_honors_each_surface_flag(): void
{
config([
'captcha.enabled' => true,
'captcha.active.login' => true,
'captcha.active.register' => false,
'captcha.active.forgotpassword' => true,
'captcha.active.password_reset' => false,
'captcha.active.curated_register' => true,
]);
$manager = $this->manager();
$this->assertTrue($manager->activeOn('login'));
$this->assertFalse($manager->activeOn('register'));
$this->assertTrue($manager->activeOn('forgotpassword'));
$this->assertFalse($manager->activeOn('password_reset'));
$this->assertTrue($manager->activeOn('curated_register'));
}
#[Test]
public function active_on_login_honors_surface_and_attempt_trigger(): void
{
// Surface directly active
config([
'captcha.enabled' => true,
'captcha.active.login' => true,
'captcha.triggers.login.enabled' => false,
]);
$this->assertTrue($this->manager()->activeOnLogin());
// Surface off, trigger disabled -> false
config(['captcha.active.login' => false]);
$this->assertFalse($this->manager()->activeOnLogin());
// Trigger enabled but below threshold -> false
config([
'captcha.triggers.login.enabled' => true,
'captcha.triggers.login.attempts' => 2,
]);
$session = $this->app['session']->driver();
request()->setLaravelSession($session);
$session->put('login_attempts', 1);
$this->assertFalse($this->manager()->activeOnLogin());
// Trigger enabled and at threshold -> true
$session->put('login_attempts', 2);
$this->assertTrue($this->manager()->activeOnLogin());
}
#[Test]
public function cap_widget_defaults_to_latest_when_no_version_set(): void
{
config([
'captcha.driver' => 'cap',
'captcha.cap.endpoint' => 'https://cap.example.com/site-key/',
'captcha.cap.widget_version' => null,
]);
$scripts = $this->manager()->active()->scripts();
$this->assertStringContainsString('@cap.js/widget@latest', $scripts);
}
#[Test]
public function cap_widget_loads_from_cdn_with_pinned_version(): void
{
config([
'captcha.driver' => 'cap',
'captcha.cap.endpoint' => 'https://cap.example.com/site-key/',
'captcha.cap.widget_version' => '0.1.57',
]);
$driver = $this->manager()->active();
$scripts = $driver->scripts();
$this->assertStringContainsString('cdn.jsdelivr.net/npm/@cap.js/widget@0.1.57', $scripts);
// Must not reference self-hosted assets anymore.
$this->assertStringNotContainsString('vendor/cap/', $scripts);
$markup = $driver->render(['data-theme' => 'dark']);
$this->assertStringContainsString('<cap-widget', $markup);
$this->assertStringContainsString('data-cap-api-endpoint="https://cap.example.com/site-key/"', $markup);
$this->assertStringContainsString('data-cap-hidden-field-name="cap-token"', $markup);
}
#[Test]
public function turnstile_renders_widget_with_sitekey(): void
{
config([
'captcha.driver' => 'turnstile',
'captcha.turnstile.sitekey' => '0xTESTSITEKEY',
]);
$markup = $this->manager()->active()->render();
$this->assertStringContainsString('cf-turnstile', $markup);
$this->assertStringContainsString('data-sitekey="0xTESTSITEKEY"', $markup);
}
}

@ -0,0 +1,124 @@
<?php
namespace Tests\Feature;
use App\Services\Captcha\TurnstileDriver;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class CaptchaVerificationTest extends TestCase
{
#[Test]
public function captcha_verify_rule_fails_when_token_is_missing(): void
{
config(['captcha.driver' => 'turnstile']);
$this->app->forgetInstance('captcha.manager');
Http::fake(); // no verify request should be made for a missing token
// Mirrors real controller usage: required|captcha_verify.
$validator = Validator::make(
[],
['cf-turnstile-response' => 'required|captcha_verify']
);
$this->assertTrue($validator->fails());
Http::assertNothingSent();
}
#[Test]
public function captcha_verify_rule_passes_when_provider_confirms(): void
{
config([
'captcha.driver' => 'turnstile',
'captcha.turnstile.secret' => 'sekret',
]);
$this->app->forgetInstance('captcha.manager');
Http::fake([
'challenges.cloudflare.com/*' => Http::response(['success' => true], 200),
]);
$validator = Validator::make(
['cf-turnstile-response' => 'a-token'],
['cf-turnstile-response' => 'captcha_verify']
);
$this->assertTrue($validator->passes());
}
#[Test]
public function captcha_verify_rule_fails_when_provider_rejects(): void
{
config([
'captcha.driver' => 'turnstile',
'captcha.turnstile.secret' => 'sekret',
]);
$this->app->forgetInstance('captcha.manager');
Http::fake([
'challenges.cloudflare.com/*' => Http::response(['success' => false], 200),
]);
$validator = Validator::make(
['cf-turnstile-response' => 'bad-token'],
['cf-turnstile-response' => 'captcha_verify']
);
$this->assertTrue($validator->fails());
}
#[Test]
public function turnstile_fail_open_lets_requests_through_on_network_error(): void
{
config([
'captcha.turnstile.secret' => 'sekret',
'captcha.turnstile.fail_open' => true,
]);
Http::fake([
'challenges.cloudflare.com/*' => Http::response('boom', 500),
]);
$driver = new TurnstileDriver;
$this->assertTrue($driver->verify(['cf-turnstile-response' => 'anything']));
}
#[Test]
public function turnstile_fail_closed_blocks_requests_on_network_error(): void
{
config([
'captcha.turnstile.secret' => 'sekret',
'captcha.turnstile.fail_open' => false,
]);
Http::fake([
'challenges.cloudflare.com/*' => Http::response('boom', 500),
]);
$driver = new TurnstileDriver;
$this->assertFalse($driver->verify(['cf-turnstile-response' => 'anything']));
}
#[Test]
public function turnstile_sends_secret_and_response(): void
{
config(['captcha.turnstile.secret' => 'my-secret']);
Http::fake([
'challenges.cloudflare.com/*' => Http::response(['success' => true], 200),
]);
(new TurnstileDriver)->verify(['cf-turnstile-response' => 'my-token']);
Http::assertSent(function ($request) {
return $request->url() === 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
&& $request['secret'] === 'my-secret'
&& $request['response'] === 'my-token';
});
}
}
Loading…
Cancel
Save