diff --git a/.env.example b/.env.example index 36a800a76..a75f5ffa8 100644 --- a/.env.example +++ b/.env.example @@ -108,13 +108,15 @@ CAPTCHA_SITEKEY= 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) +# Base URL WITHOUT the site key CAP_ENDPOINT= +CAP_SITEKEY= CAP_SECRET= +CAP_TOKEN_FIELD=cap-captcha-response CAP_TIMEOUT=5 CAP_FAIL_OPEN=false +# @cap.js/widget version from jsDelivr; leave "latest" to track newest stable CAP_WIDGET_VERSION=latest diff --git a/app/Http/Controllers/Admin/AdminSettingsController.php b/app/Http/Controllers/Admin/AdminSettingsController.php index d47f2c68e..94289cb7f 100644 --- a/app/Http/Controllers/Admin/AdminSettingsController.php +++ b/app/Http/Controllers/Admin/AdminSettingsController.php @@ -733,11 +733,14 @@ trait AdminSettingsController ConfigCacheService::put('captcha.turnstile.sitekey', $request->input('captcha_turnstile_sitekey')); } - // Cap credentials (endpoint is public, store as-is when present) + // Cap credentials (endpoint + sitekey are public, store as-is) $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')); } + if ($request->filled('captcha_cap_sitekey')) { + ConfigCacheService::put('captcha.cap.sitekey', $request->input('captcha_cap_sitekey')); + } ConfigCacheService::put('captcha.active.login', $request->boolean('captcha_on_login')); ConfigCacheService::put('captcha.active.register', $request->boolean('captcha_on_register')); @@ -768,6 +771,7 @@ trait AdminSettingsController '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_sitekey' => $request->input('captcha_cap_sitekey'), 'captcha_cap_secret' => $request->input('captcha_cap_secret'), 'custom_emoji_enabled' => $request->boolean('custom_emoji_enabled'), ]; diff --git a/app/Services/AdminSettingsService.php b/app/Services/AdminSettingsService.php index 1305b4f96..6476bd97a 100644 --- a/app/Services/AdminSettingsService.php +++ b/app/Services/AdminSettingsService.php @@ -142,6 +142,7 @@ class AdminSettingsService '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_sitekey' => config_cache('captcha.cap.sitekey'), 'captcha_cap_secret' => self::maskSecret(config_cache('captcha.cap.secret')), 'custom_emoji_enabled' => (bool) config_cache('federation.custom_emoji.enabled'), ]; diff --git a/app/Services/Captcha/CapDriver.php b/app/Services/Captcha/CapDriver.php index c46ea8919..998f148b9 100644 --- a/app/Services/Captcha/CapDriver.php +++ b/app/Services/Captcha/CapDriver.php @@ -10,7 +10,12 @@ 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. + * @cap.js/widget from the jsDelivr CDN. + * + * The full API endpoint the widget and verifier talk to is composed from a base + * URL (captcha.cap.endpoint) plus the site key (captcha.cap.sitekey): + * + * https://cap.example.com + 3c87a0e810 => https://cap.example.com/3c87a0e810/ * * @see https://github.com/oliweb-ch/laravel-cap */ @@ -30,6 +35,7 @@ class CapDriver implements CaptchaDriver public function isConfigured(): bool { return ! empty(config_cache('captcha.cap.endpoint')) + && ! empty(config_cache('captcha.cap.sitekey')) && ! empty(config_cache('captcha.cap.secret')); } @@ -38,6 +44,24 @@ class CapDriver implements CaptchaDriver return (string) config('captcha.cap.token_field', 'cap-token'); } + /** + * Compose the full Cap API endpoint: "{base}/{sitekey}/". + * + * The base URL is the instance origin without the site key. The site key is + * appended as a path segment with a trailing slash (required by Cap). + */ + public function apiEndpoint(): string + { + $base = rtrim(trim((string) config_cache('captcha.cap.endpoint')), '/'); + $sitekey = trim((string) config_cache('captcha.cap.sitekey'), '/ '); + + if ($base === '' || $sitekey === '') { + return ''; + } + + return $base.'/'.$sitekey.'/'; + } + public function verify(array $input): bool { $token = $input[$this->responseField()] ?? null; @@ -46,8 +70,13 @@ class CapDriver implements CaptchaDriver return false; } + $endpoint = $this->apiEndpoint(); + if ($endpoint === '') { + return false; + } + $cap = new Cap(app(HttpFactory::class), [ - 'endpoint' => config_cache('captcha.cap.endpoint'), + 'endpoint' => $endpoint, 'secret' => config_cache('captcha.cap.secret'), 'timeout' => (int) config('captcha.cap.timeout', 5), 'fail_open' => (bool) config('captcha.cap.fail_open', false), @@ -58,7 +87,7 @@ class CapDriver implements CaptchaDriver public function render(array $attributes = []): string { - $endpoint = e((string) config_cache('captcha.cap.endpoint')); + $endpoint = e($this->apiEndpoint()); $field = e($this->responseField()); $attrs = ''; diff --git a/app/Services/ConfigCacheService.php b/app/Services/ConfigCacheService.php index a12039030..5c6f95971 100644 --- a/app/Services/ConfigCacheService.php +++ b/app/Services/ConfigCacheService.php @@ -115,6 +115,7 @@ class ConfigCacheService 'captcha.turnstile.secret', 'captcha.turnstile.sitekey', 'captcha.cap.endpoint', + 'captcha.cap.sitekey', 'captcha.cap.secret', 'captcha.active.login', 'captcha.active.register', diff --git a/config/captcha.php b/config/captcha.php index ec5750c22..1f8cb0966 100644 --- a/config/captcha.php +++ b/config/captcha.php @@ -63,11 +63,13 @@ return [ |-------------------------------------------------------------------------- | 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/ + | The endpoint is the instance base URL WITHOUT the site key, e.g. + | https://cap.example.com. The site key is a separate value; the full API + | endpoint (https://cap.example.com/your-site-key/) is composed by CapDriver. */ 'cap' => [ 'endpoint' => env('CAP_ENDPOINT'), + 'sitekey' => env('CAP_SITEKEY'), 'secret' => env('CAP_SECRET'), 'token_field' => env('CAP_TOKEN_FIELD', 'cap-token'), 'timeout' => (int) env('CAP_TIMEOUT', 5), diff --git a/resources/assets/components/admin/AdminSettings.vue b/resources/assets/components/admin/AdminSettings.vue index b8f5c238a..76b27832d 100644 --- a/resources/assets/components/admin/AdminSettings.vue +++ b/resources/assets/components/admin/AdminSettings.vue @@ -460,15 +460,26 @@