diff --git a/.env.captcha b/.env.captcha index 4580f2892..5ee96e4c0 100644 --- a/.env.captcha +++ b/.env.captcha @@ -14,8 +14,8 @@ CAPTCHA_ENABLED_ON_PASSWORD_RESET=false CAPTCHA_ENABLED_ON_CURATED_REGISTER=false # --- hCaptcha (driver: hcaptcha) --- -CAPTCHA_SECRET= -CAPTCHA_SITEKEY= +CAPTCHA_H_SITEKEY= +CAPTCHA_H_SECRET= # --- Cloudflare Turnstile (driver: turnstile) --- CAPTCHA_TURNSTILE_SITEKEY= diff --git a/app/Http/Controllers/Admin/AdminSettingsController.php b/app/Http/Controllers/Admin/AdminSettingsController.php index 94289cb7f..f4f507521 100644 --- a/app/Http/Controllers/Admin/AdminSettingsController.php +++ b/app/Http/Controllers/Admin/AdminSettingsController.php @@ -718,14 +718,10 @@ trait AdminSettingsController }; // 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); + // keys. CaptchaServiceProvider hydrates the top-level captcha.secret + // / captcha.sitekey that the buzz/laravel-h-captcha package reads. + $putIfChanged('captcha.hcaptcha.secret', $request->input('captcha_hcaptcha_secret')); + $putIfChanged('captcha.hcaptcha.sitekey', $request->input('captcha_hcaptcha_sitekey')); // Turnstile credentials (sitekey is public, store as-is when present) $putIfChanged('captcha.turnstile.secret', $request->input('captcha_turnstile_secret')); diff --git a/app/Providers/CaptchaServiceProvider.php b/app/Providers/CaptchaServiceProvider.php index 33ca46881..09c661876 100644 --- a/app/Providers/CaptchaServiceProvider.php +++ b/app/Providers/CaptchaServiceProvider.php @@ -17,10 +17,42 @@ class CaptchaServiceProvider extends ServiceProvider public function boot(): void { + $this->hydrateHcaptchaConfig(); $this->registerValidationRule(); $this->registerBladeDirectives(); } + /** + * The buzz/laravel-h-captcha package reads its config at the TOP LEVEL of + * the "captcha" config (captcha.secret, captcha.sitekey, captcha.http_client, + * captcha.options, captcha.attributes) via the plain config repository — it + * does not consult the DB-backed config cache. + * + * We keep all hCaptcha config under captcha.hcaptcha.*, so hydrate the + * top-level keys the package expects here. Secret/sitekey use config_cache() + * so admin-panel values (not just .env) are honored; the static widget + * options come straight from the config file. + */ + private function hydrateHcaptchaConfig(): void + { + $secret = config_cache('captcha.hcaptcha.secret'); + $sitekey = config_cache('captcha.hcaptcha.sitekey'); + + if (! empty($secret)) { + config(['captcha.secret' => $secret]); + } + + if (! empty($sitekey)) { + config(['captcha.sitekey' => $sitekey]); + } + + config([ + 'captcha.http_client' => config('captcha.hcaptcha.http_client'), + 'captcha.options' => config('captcha.hcaptcha.options'), + 'captcha.attributes' => config('captcha.hcaptcha.attributes'), + ]); + } + /** * Driver-agnostic validation rule. * diff --git a/app/Services/Captcha/CapDriver.php b/app/Services/Captcha/CapDriver.php index 998f148b9..6829d46da 100644 --- a/app/Services/Captcha/CapDriver.php +++ b/app/Services/Captcha/CapDriver.php @@ -10,6 +10,7 @@ use LaravelCap\Cap; * Cap driver (self-hosted proof-of-work CAPTCHA). * * Wraps the oliweb/laravel-cap package for verification, and renders the + * * @cap.js/widget from the jsDelivr CDN. * * The full API endpoint the widget and verifier talk to is composed from a base diff --git a/config/captcha.php b/config/captcha.php index 1f8cb0966..c4f536c43 100644 --- a/config/captcha.php +++ b/config/captcha.php @@ -24,27 +24,24 @@ return [ |-------------------------------------------------------------------------- | hCaptcha |-------------------------------------------------------------------------- - | Canonical, provider-namespaced credentials used throughout the app. + | Canonical, provider-namespaced hCaptcha config used throughout the app. + | + | The buzz/laravel-h-captcha package reads these values at the top level of + | the "captcha" config (captcha.secret, captcha.sitekey, captcha.http_client, + | captcha.options, captcha.attributes). CaptchaServiceProvider hydrates those + | top-level keys from this block at boot, so everything lives here. */ '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'), - 'sitekey' => env('CAPTCHA_SITEKEY', 'default_sitekey'), - 'http_client' => HttpClient::class, - 'options' => [ - 'multiple' => false, - 'lang' => app()->getLocale(), - ], - 'attributes' => [ - 'theme' => 'light', + 'secret' => env('CAPTCHA_H_SECRET', 'default_secret'), + 'sitekey' => env('CAPTCHA_H_SITEKEY', 'default_sitekey'), + 'http_client' => HttpClient::class, + 'options' => [ + 'multiple' => false, + 'lang' => app()->getLocale(), + ], + 'attributes' => [ + 'theme' => 'light', + ], ], /* diff --git a/tests/Feature/CaptchaHydrationTest.php b/tests/Feature/CaptchaHydrationTest.php new file mode 100644 index 000000000..25eddcd6e --- /dev/null +++ b/tests/Feature/CaptchaHydrationTest.php @@ -0,0 +1,78 @@ +app))->boot(); + } + + #[Test] + public function it_hydrates_buzz_config_from_admin_managed_hcaptcha_keys(): void + { + config([ + 'captcha.hcaptcha.secret' => 'admin-saved-secret', + 'captcha.hcaptcha.sitekey' => 'admin-saved-sitekey', + // Stale/.env-only values the buzz package would otherwise read. + 'captcha.secret' => 'default_secret', + 'captcha.sitekey' => 'default_sitekey', + ]); + + $this->bootProvider(); + + $this->assertSame('admin-saved-secret', config('captcha.secret')); + $this->assertSame('admin-saved-sitekey', config('captcha.sitekey')); + } + + #[Test] + public function it_hydrates_widget_options_from_the_hcaptcha_block(): void + { + config([ + 'captcha.hcaptcha.http_client' => 'Some\\Custom\\Client', + 'captcha.hcaptcha.options' => ['multiple' => true, 'lang' => 'fr'], + 'captcha.hcaptcha.attributes' => ['theme' => 'dark'], + // Ensure the top-level keys start out different. + 'captcha.http_client' => null, + 'captcha.options' => null, + 'captcha.attributes' => null, + ]); + + $this->bootProvider(); + + $this->assertSame('Some\\Custom\\Client', config('captcha.http_client')); + $this->assertSame(['multiple' => true, 'lang' => 'fr'], config('captcha.options')); + $this->assertSame(['theme' => 'dark'], config('captcha.attributes')); + } + + #[Test] + public function it_leaves_buzz_config_untouched_when_hcaptcha_keys_are_empty(): void + { + config([ + 'captcha.hcaptcha.secret' => '', + 'captcha.hcaptcha.sitekey' => null, + 'captcha.secret' => 'env_secret', + 'captcha.sitekey' => 'env_sitekey', + ]); + + $this->bootProvider(); + + // Nothing to hydrate -> the existing (env/config-file) values remain. + $this->assertSame('env_secret', config('captcha.secret')); + $this->assertSame('env_sitekey', config('captcha.sitekey')); + } +}