diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index 45ffdad6..3dac1cf5 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; use App\Http\Requests\Auth\LoginRequest; +use App\Services\Security\CaptchaVerifier; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -14,9 +15,17 @@ class AuthenticatedSessionController extends Controller /** * Display the login view. */ + public function __construct( + private readonly CaptchaVerifier $captchaVerifier, + ) { + } + public function create(): View { - return view('auth.login'); + return view('auth.login', [ + 'requiresCaptcha' => session('bot_captcha_required', false), + 'captcha' => $this->captchaVerifier->frontendConfig(), + ]); } /** diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 76c9ea65..56454daa 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -8,7 +8,7 @@ use App\Models\EmailSendEvent; use App\Models\User; use App\Services\Auth\DisposableEmailService; use App\Services\Auth\RegistrationVerificationTokenService; -use App\Services\Security\TurnstileVerifier; +use App\Services\Security\CaptchaVerifier; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; @@ -19,7 +19,7 @@ use Illuminate\View\View; class RegisteredUserController extends Controller { public function __construct( - private readonly TurnstileVerifier $turnstileVerifier, + private readonly CaptchaVerifier $captchaVerifier, private readonly DisposableEmailService $disposableEmailService, private readonly RegistrationVerificationTokenService $verificationTokenService, ) @@ -33,8 +33,8 @@ class RegisteredUserController extends Controller { return view('auth.register', [ 'prefillEmail' => (string) $request->query('email', ''), - 'requiresTurnstile' => $this->shouldRequireTurnstile($request->ip()), - 'turnstileSiteKey' => (string) config('services.turnstile.site_key', ''), + 'requiresCaptcha' => $this->shouldRequireCaptcha($request->ip()), + 'captcha' => $this->captchaVerifier->frontendConfig(), ]); } @@ -56,20 +56,22 @@ class RegisteredUserController extends Controller */ public function store(Request $request): RedirectResponse { - $validated = $request->validate([ + $rules = [ 'email' => ['required', 'string', 'lowercase', 'email', 'max:255'], 'website' => ['nullable', 'max:0'], - 'cf-turnstile-response' => ['nullable', 'string'], - ]); + ]; + $rules[$this->captchaVerifier->inputName()] = ['nullable', 'string']; + + $validated = $request->validate($rules); $email = strtolower(trim((string) $validated['email'])); $ip = $request->ip(); $this->trackRegisterAttempt($ip); - if ($this->shouldRequireTurnstile($ip)) { - $verified = $this->turnstileVerifier->verify( - (string) $request->input('cf-turnstile-response', ''), + if ($this->shouldRequireCaptcha($ip)) { + $verified = $this->captchaVerifier->verify( + (string) $request->input($this->captchaVerifier->inputName(), ''), $ip ); @@ -199,9 +201,9 @@ class RegisteredUserController extends Controller ]); } - private function shouldRequireTurnstile(?string $ip): bool + private function shouldRequireCaptcha(?string $ip): bool { - if (! $this->turnstileVerifier->isEnabled()) { + if (! $this->captchaVerifier->isEnabled()) { return false; } diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index ce3472d0..ba555aa8 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -17,6 +17,7 @@ use App\Models\Artwork; use App\Models\ProfileComment; use App\Models\Story; use App\Models\User; +use App\Services\Security\CaptchaVerifier; use App\Services\AvatarService; use App\Services\ArtworkService; use App\Services\FollowService; @@ -47,6 +48,7 @@ class ProfileController extends Controller private readonly UsernameApprovalService $usernameApprovalService, private readonly FollowService $followService, private readonly UserStatsService $userStats, + private readonly CaptchaVerifier $captchaVerifier, ) { } @@ -240,7 +242,9 @@ class ProfileController extends Controller 'flash' => [ 'status' => session('status'), 'error' => session('error'), + 'botCaptchaRequired' => session('bot_captcha_required', false), ], + 'captcha' => $this->captchaVerifier->frontendConfig(), ])->rootView('settings'); } diff --git a/app/Http/Middleware/ForumAIModerationMiddleware.php b/app/Http/Middleware/ForumAIModerationMiddleware.php new file mode 100644 index 00000000..52feca9b --- /dev/null +++ b/app/Http/Middleware/ForumAIModerationMiddleware.php @@ -0,0 +1,50 @@ + $action, + 'ai_spam_score' => 0, + 'ai_toxicity_score' => 0, + 'flags' => [], + 'reasons' => [], + 'provider' => 'none', + 'available' => false, + 'raw' => null, + 'language' => null, + ]; + + $title = trim((string) $request->input('title', '')); + $content = trim((string) $request->input('content', '')); + $combinedContent = trim($title !== '' ? $title . "\n" . $content : $content); + + if ( + $combinedContent !== '' + && (bool) config('skinbase_ai_moderation.enabled', true) + && (bool) config('skinbase_ai_moderation.preflight.run_ai_sync', true) + ) { + $spamAssessment = $request->attributes->get('forum_spam_assessment'); + $assessment = ['action' => $action] + $this->aiContentModerator->analyze($combinedContent, [ + 'links' => is_array($spamAssessment) ? (array) ($spamAssessment['links'] ?? []) : [], + ]); + } + + $request->attributes->set('forum_ai_assessment', $assessment); + + return $next($request); + } +} \ No newline at end of file diff --git a/app/Http/Middleware/ForumBotProtectionMiddleware.php b/app/Http/Middleware/ForumBotProtectionMiddleware.php new file mode 100644 index 00000000..693cbf82 --- /dev/null +++ b/app/Http/Middleware/ForumBotProtectionMiddleware.php @@ -0,0 +1,96 @@ +botProtectionService->assess($request, $action); + $request->attributes->set('forum_bot_assessment', $assessment); + + if ($this->requiresCaptcha($assessment, $action)) { + $captcha = $this->captchaVerifier->frontendConfig(); + $tokenInput = (string) ($captcha['inputName'] ?? $this->captchaVerifier->inputName()); + $token = (string) ( + $request->input($tokenInput) + ?: $request->header('X-Captcha-Token', '') + ?: $request->header('X-Turnstile-Token', '') + ); + + if (! $this->captchaVerifier->verify($token, $request->ip())) { + $message = (string) config('forum_bot_protection.captcha.message', 'Complete the captcha challenge to continue.'); + + if ($request->expectsJson()) { + return response()->json([ + 'message' => $message, + 'errors' => [ + 'captcha' => [$message], + ], + 'requires_captcha' => true, + 'captcha' => $captcha, + 'captcha_provider' => (string) ($captcha['provider'] ?? $this->captchaVerifier->provider()), + 'captcha_site_key' => (string) ($captcha['siteKey'] ?? ''), + 'captcha_input' => (string) ($captcha['inputName'] ?? $tokenInput), + 'captcha_script_url' => (string) ($captcha['scriptUrl'] ?? ''), + ], 422); + } + + return redirect()->back() + ->withInput($request->except(['password', 'current_password', 'new_password', 'new_password_confirmation', $tokenInput])) + ->withErrors(['captcha' => $message]) + ->with('bot_captcha_required', true) + ->with('bot_turnstile_required', true); + } + + $request->attributes->set('forum_bot_captcha_verified', true); + } + + if ((bool) ($assessment['blocked'] ?? false)) { + $message = 'Suspicious activity detected.'; + + if ($request->expectsJson()) { + return response()->json([ + 'message' => $message, + 'errors' => [ + 'bot' => [$message], + ], + ], 429); + } + + throw ValidationException::withMessages([ + 'bot' => [$message], + ]); + } + + return $next($request); + } + + private function requiresCaptcha(array $assessment, string $action): bool + { + if (! $this->captchaVerifier->isEnabled()) { + return false; + } + + if ((int) ($assessment['risk_score'] ?? 0) < (int) config('forum_bot_protection.thresholds.captcha', 60)) { + return false; + } + + return in_array($action, (array) config('forum_bot_protection.captcha.actions', []), true); + } +} diff --git a/app/Http/Middleware/ForumRateLimitMiddleware.php b/app/Http/Middleware/ForumRateLimitMiddleware.php new file mode 100644 index 00000000..1f4068be --- /dev/null +++ b/app/Http/Middleware/ForumRateLimitMiddleware.php @@ -0,0 +1,70 @@ +route())->getName(); + $limiterName = match ($routeName) { + 'forum.topic.store' => 'forum-thread-create', + default => 'forum-post-write', + }; + + try { + return $this->throttleRequests->handle($request, $next, $limiterName); + } catch (ThrottleRequestsException $exception) { + $maxAttempts = (int) ($exception->getHeaders()['X-RateLimit-Limit'] ?? 0); + + $this->botProtectionService->recordRateLimitViolation( + $request, + $this->resolveActionName($routeName), + [ + 'limiter' => $limiterName, + 'bucket' => $this->resolveBucket($limiterName, $maxAttempts), + 'max_attempts' => $maxAttempts, + 'retry_after' => (int) ($exception->getHeaders()['Retry-After'] ?? 0), + 'reason' => sprintf('Forum write rate limit exceeded on %s.', $routeName !== '' ? $routeName : 'unknown route'), + ], + ); + + throw $exception; + } + } + + private function resolveActionName(string $routeName): string + { + return match ($routeName) { + 'forum.topic.store' => 'forum_topic_create', + 'forum.post.update' => 'forum_post_update', + default => 'forum_reply_create', + }; + } + + private function resolveBucket(string $limiterName, int $maxAttempts): string + { + return $maxAttempts <= $this->minuteLimitThreshold($limiterName) ? 'minute' : 'hour'; + } + + private function minuteLimitThreshold(string $limiterName): int + { + return match ($limiterName) { + 'forum-thread-create', 'forum-post-write' => 3, + default => PHP_INT_MAX, + }; + } +} diff --git a/app/Http/Middleware/ForumSecurityFirewallMiddleware.php b/app/Http/Middleware/ForumSecurityFirewallMiddleware.php new file mode 100644 index 00000000..fbac864a --- /dev/null +++ b/app/Http/Middleware/ForumSecurityFirewallMiddleware.php @@ -0,0 +1,90 @@ +firewallService->assess($request, $action); + $request->attributes->set('forum_firewall_assessment', $assessment); + + if ($this->requiresCaptcha($assessment, $action)) { + $captcha = $this->captchaVerifier->frontendConfig(); + $tokenInput = (string) ($captcha['inputName'] ?? $this->captchaVerifier->inputName()); + $token = (string) ( + $request->input($tokenInput) + ?: $request->header('X-Captcha-Token', '') + ?: $request->header('X-Turnstile-Token', '') + ); + + if (! $this->captchaVerifier->verify($token, $request->ip())) { + $message = 'Additional verification is required before continuing.'; + + if ($request->expectsJson()) { + return response()->json([ + 'message' => $message, + 'errors' => [ + 'captcha' => [$message], + ], + 'requires_captcha' => true, + 'captcha' => $captcha, + ], 422); + } + + return redirect()->back() + ->withInput($request->except(['password', 'current_password', 'new_password', 'new_password_confirmation', $tokenInput])) + ->withErrors(['captcha' => $message]); + } + + $request->attributes->set('forum_firewall_captcha_verified', true); + } + + if ((bool) ($assessment['blocked'] ?? false)) { + $message = 'Security firewall blocked this request.'; + + if ($request->expectsJson()) { + return response()->json([ + 'message' => $message, + 'errors' => [ + 'security' => [$message], + ], + ], 429); + } + + throw ValidationException::withMessages([ + 'security' => [$message], + ]); + } + + return $next($request); + } + + private function requiresCaptcha(array $assessment, string $action): bool + { + if (! $this->captchaVerifier->isEnabled()) { + return false; + } + + if (! (bool) ($assessment['requires_captcha'] ?? false)) { + return false; + } + + return in_array($action, (array) config('forum_bot_protection.captcha.actions', []), true); + } +} \ No newline at end of file diff --git a/app/Http/Middleware/ForumSpamDetectionMiddleware.php b/app/Http/Middleware/ForumSpamDetectionMiddleware.php new file mode 100644 index 00000000..70c940f9 --- /dev/null +++ b/app/Http/Middleware/ForumSpamDetectionMiddleware.php @@ -0,0 +1,66 @@ +input('title', '')); + $content = trim((string) $request->input('content', '')); + $combinedContent = trim($title !== '' ? $title . "\n" . $content : $content); + + if ($combinedContent === '') { + $request->attributes->set('forum_spam_assessment', [ + 'action' => $action, + 'spam_score' => 0, + 'spam_reasons' => [], + 'link_score' => 0, + 'link_reasons' => [], + 'links' => [], + 'domains' => [], + 'pattern_score' => 0, + 'pattern_reasons' => [], + 'matched_categories' => [], + ]); + + return $next($request); + } + + $spam = $this->spamDetector->analyze($combinedContent); + $link = $this->linkAnalyzer->analyze($combinedContent); + $patterns = $this->contentPatternAnalyzer->analyze($combinedContent); + + $request->attributes->set('forum_spam_assessment', [ + 'action' => $action, + 'spam_score' => max((int) ($spam['score'] ?? 0), (int) ($patterns['score'] ?? 0)), + 'spam_reasons' => array_values(array_unique(array_merge( + (array) ($spam['reasons'] ?? []), + (array) ($patterns['reasons'] ?? []), + ))), + 'link_score' => (int) ($link['score'] ?? 0), + 'link_reasons' => (array) ($link['reasons'] ?? []), + 'links' => (array) ($link['links'] ?? []), + 'domains' => (array) ($link['domains'] ?? []), + 'pattern_score' => (int) ($patterns['score'] ?? 0), + 'pattern_reasons' => (array) ($patterns['reasons'] ?? []), + 'matched_categories' => (array) ($patterns['matched_categories'] ?? []), + ]); + + return $next($request); + } +} \ No newline at end of file diff --git a/app/Models/ForumPost.php b/app/Models/ForumPost.php index 2475b440..61c94e1f 100644 --- a/app/Models/ForumPost.php +++ b/app/Models/ForumPost.php @@ -18,15 +18,28 @@ class ForumPost extends Model 'id', 'thread_id', 'topic_id', + 'source_ip_hash', 'user_id', 'content', + 'content_hash', 'is_edited', 'edited_at', 'spam_score', 'quality_score', + 'ai_spam_score', + 'ai_toxicity_score', + 'behavior_score', + 'link_score', + 'learning_score', + 'risk_score', + 'trust_modifier', 'flagged', 'flagged_reason', 'moderation_checked', + 'moderation_status', + 'moderation_labels', + 'moderation_meta', + 'last_ai_scan_at', ]; public $incrementing = true; @@ -36,8 +49,18 @@ class ForumPost extends Model 'edited_at' => 'datetime', 'spam_score' => 'integer', 'quality_score' => 'integer', + 'ai_spam_score' => 'integer', + 'ai_toxicity_score' => 'integer', + 'behavior_score' => 'integer', + 'link_score' => 'integer', + 'learning_score' => 'integer', + 'risk_score' => 'integer', + 'trust_modifier' => 'integer', 'flagged' => 'boolean', 'moderation_checked' => 'boolean', + 'moderation_labels' => 'array', + 'moderation_meta' => 'array', + 'last_ai_scan_at' => 'datetime', ]; public function thread(): BelongsTo diff --git a/app/Models/User.php b/app/Models/User.php index a4ce12dd..205e7dc5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -44,6 +44,12 @@ class User extends Authenticatable 'cover_ext', 'cover_position', 'trust_score', + 'bot_risk_score', + 'bot_flags', + 'last_bot_activity_at', + 'spam_reports', + 'approved_posts', + 'flagged_posts', 'password', 'role', 'allow_messages_from', @@ -76,6 +82,12 @@ class User extends Authenticatable 'deleted_at' => 'datetime', 'cover_position' => 'integer', 'trust_score' => 'integer', + 'bot_risk_score' => 'integer', + 'bot_flags' => 'array', + 'last_bot_activity_at' => 'datetime', + 'spam_reports' => 'integer', + 'approved_posts' => 'integer', + 'flagged_posts' => 'integer', 'password' => 'hashed', 'allow_messages_from' => 'string', ]; diff --git a/app/Services/Security/Captcha/CaptchaProviderInterface.php b/app/Services/Security/Captcha/CaptchaProviderInterface.php new file mode 100644 index 00000000..4b97d172 --- /dev/null +++ b/app/Services/Security/Captcha/CaptchaProviderInterface.php @@ -0,0 +1,18 @@ +siteKey() !== '' + && (string) config('services.hcaptcha.secret', '') !== ''; + } + + public function siteKey(): string + { + return (string) config('services.hcaptcha.site_key', ''); + } + + public function inputName(): string + { + return 'h-captcha-response'; + } + + public function scriptUrl(): string + { + return (string) config('services.hcaptcha.script_url', 'https://js.hcaptcha.com/1/api.js'); + } + + public function verify(string $token, ?string $ip = null): bool + { + if (! $this->isEnabled()) { + return true; + } + + if (trim($token) === '') { + return false; + } + + try { + $response = Http::asForm() + ->timeout((int) config('services.hcaptcha.timeout', 5)) + ->post((string) config('services.hcaptcha.verify_url', 'https://hcaptcha.com/siteverify'), [ + 'secret' => (string) config('services.hcaptcha.secret', ''), + 'response' => $token, + 'remoteip' => $ip, + ]); + + if ($response->failed()) { + return false; + } + + return (bool) data_get($response->json(), 'success', false); + } catch (\Throwable $exception) { + Log::warning('hcaptcha verification request failed', [ + 'message' => $exception->getMessage(), + ]); + + return false; + } + } +} diff --git a/app/Services/Security/Captcha/RecaptchaCaptchaProvider.php b/app/Services/Security/Captcha/RecaptchaCaptchaProvider.php new file mode 100644 index 00000000..146172c6 --- /dev/null +++ b/app/Services/Security/Captcha/RecaptchaCaptchaProvider.php @@ -0,0 +1,69 @@ +siteKey() !== '' + && (string) config('services.recaptcha.secret', '') !== ''; + } + + public function siteKey(): string + { + return (string) config('services.recaptcha.site_key', ''); + } + + public function inputName(): string + { + return 'g-recaptcha-response'; + } + + public function scriptUrl(): string + { + return (string) config('services.recaptcha.script_url', 'https://www.google.com/recaptcha/api.js'); + } + + public function verify(string $token, ?string $ip = null): bool + { + if (! $this->isEnabled()) { + return true; + } + + if (trim($token) === '') { + return false; + } + + try { + $response = Http::asForm() + ->timeout((int) config('services.recaptcha.timeout', 5)) + ->post((string) config('services.recaptcha.verify_url', 'https://www.google.com/recaptcha/api/siteverify'), [ + 'secret' => (string) config('services.recaptcha.secret', ''), + 'response' => $token, + 'remoteip' => $ip, + ]); + + if ($response->failed()) { + return false; + } + + return (bool) data_get($response->json(), 'success', false); + } catch (\Throwable $exception) { + Log::warning('recaptcha verification request failed', [ + 'message' => $exception->getMessage(), + ]); + + return false; + } + } +} diff --git a/app/Services/Security/Captcha/TurnstileCaptchaProvider.php b/app/Services/Security/Captcha/TurnstileCaptchaProvider.php new file mode 100644 index 00000000..f286610a --- /dev/null +++ b/app/Services/Security/Captcha/TurnstileCaptchaProvider.php @@ -0,0 +1,69 @@ +siteKey() !== '' + && (string) config('services.turnstile.secret_key', '') !== ''; + } + + public function siteKey(): string + { + return (string) config('services.turnstile.site_key', ''); + } + + public function inputName(): string + { + return 'cf-turnstile-response'; + } + + public function scriptUrl(): string + { + return (string) config('services.turnstile.script_url', 'https://challenges.cloudflare.com/turnstile/v0/api.js'); + } + + public function verify(string $token, ?string $ip = null): bool + { + if (! $this->isEnabled()) { + return true; + } + + if (trim($token) === '') { + return false; + } + + try { + $response = Http::asForm() + ->timeout((int) config('services.turnstile.timeout', 5)) + ->post((string) config('services.turnstile.verify_url', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'), [ + 'secret' => (string) config('services.turnstile.secret_key', ''), + 'response' => $token, + 'remoteip' => $ip, + ]); + + if ($response->failed()) { + return false; + } + + return (bool) data_get($response->json(), 'success', false); + } catch (\Throwable $exception) { + Log::warning('turnstile verification request failed', [ + 'message' => $exception->getMessage(), + ]); + + return false; + } + } +} diff --git a/app/Services/Security/CaptchaVerifier.php b/app/Services/Security/CaptchaVerifier.php new file mode 100644 index 00000000..bcdbf699 --- /dev/null +++ b/app/Services/Security/CaptchaVerifier.php @@ -0,0 +1,71 @@ + 'recaptcha', + 'hcaptcha' => 'hcaptcha', + default => 'turnstile', + }; + } + + public function isEnabled(): bool + { + return $this->resolveProvider()->isEnabled(); + } + + public function inputName(): string + { + $configured = trim((string) config('forum_bot_protection.captcha.input', '')); + + if ($configured !== '') { + return $configured; + } + + return $this->resolveProvider()->inputName(); + } + + public function verify(string $token, ?string $ip = null): bool + { + return $this->resolveProvider()->verify($token, $ip); + } + + public function frontendConfig(): array + { + $provider = $this->resolveProvider(); + + return [ + 'provider' => $provider->name(), + 'siteKey' => $provider->isEnabled() ? $provider->siteKey() : '', + 'inputName' => $this->inputName(), + 'scriptUrl' => $provider->isEnabled() ? $provider->scriptUrl() : '', + ]; + } + + private function resolveProvider(): CaptchaProviderInterface + { + return match ($this->provider()) { + 'recaptcha' => $this->recaptchaProvider, + 'hcaptcha' => $this->hcaptchaProvider, + default => $this->turnstileProvider, + }; + } +} diff --git a/app/Services/Security/TurnstileVerifier.php b/app/Services/Security/TurnstileVerifier.php index 05965852..29cd24a4 100644 --- a/app/Services/Security/TurnstileVerifier.php +++ b/app/Services/Security/TurnstileVerifier.php @@ -2,50 +2,21 @@ namespace App\Services\Security; -use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Log; - class TurnstileVerifier { + public function __construct( + private readonly CaptchaVerifier $captchaVerifier, + ) { + } + public function isEnabled(): bool { - return (bool) config('registration.enable_turnstile', true) - && (string) config('services.turnstile.site_key', '') !== '' - && (string) config('services.turnstile.secret_key', '') !== ''; + return $this->captchaVerifier->provider() === 'turnstile' + && $this->captchaVerifier->isEnabled(); } public function verify(string $token, ?string $ip = null): bool { - if (! $this->isEnabled()) { - return true; - } - - if (trim($token) === '') { - return false; - } - - try { - $response = Http::asForm() - ->timeout((int) config('services.turnstile.timeout', 5)) - ->post((string) config('services.turnstile.verify_url', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'), [ - 'secret' => (string) config('services.turnstile.secret_key', ''), - 'response' => $token, - 'remoteip' => $ip, - ]); - - if ($response->failed()) { - return false; - } - - $payload = $response->json(); - - return (bool) data_get($payload, 'success', false); - } catch (\Throwable $exception) { - Log::warning('turnstile verification request failed', [ - 'message' => $exception->getMessage(), - ]); - - return false; - } + return $this->captchaVerifier->verify($token, $ip); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 81db6687..33988007 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,6 +12,11 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->validateCsrfTokens(except: [ + 'chat_post', + 'chat_post/*', + ]); + $middleware->web(append: [ \App\Http\Middleware\HandleInertiaRequests::class, // Runs on every web request; no-ops for guests, redirects authenticated @@ -23,6 +28,11 @@ return Application::configure(basePath: dirname(__DIR__)) 'admin.moderation' => \App\Http\Middleware\EnsureAdminOrModerator::class, 'creator.access' => \App\Http\Middleware\EnsureCreatorAccess::class, 'ensure.onboarding.complete'=> \App\Http\Middleware\EnsureOnboardingComplete::class, + 'forum.ai.moderation' => \App\Http\Middleware\ForumAIModerationMiddleware::class, + 'forum.bot.protection' => \App\Http\Middleware\ForumBotProtectionMiddleware::class, + 'forum.spam.detection' => \App\Http\Middleware\ForumSpamDetectionMiddleware::class, + 'forum.security.firewall' => \App\Http\Middleware\ForumSecurityFirewallMiddleware::class, + 'forum.rate_limit' => \App\Http\Middleware\ForumRateLimitMiddleware::class, 'onboarding' => \App\Http\Middleware\EnsureOnboardingComplete::class, 'normalize.username' => \App\Http\Middleware\NormalizeUsername::class, ]); diff --git a/config/forum_bot_protection.php b/config/forum_bot_protection.php new file mode 100644 index 00000000..e900a164 --- /dev/null +++ b/config/forum_bot_protection.php @@ -0,0 +1,147 @@ + env('FORUM_BOT_PROTECTION_ENABLED', true), + + 'thresholds' => [ + 'allow' => 20, + 'log' => 20, + 'captcha' => 40, + 'moderate' => 60, + 'block' => 80, + ], + + 'honeypots' => [ + 'fields' => ['homepage_url', 'company_name'], + 'penalty' => 60, + ], + + 'captcha' => [ + 'provider' => env('FORUM_BOT_CAPTCHA_PROVIDER', 'turnstile'), + 'actions' => [ + 'register', + 'login', + 'forum_topic_create', + 'forum_reply_create', + 'forum_post_update', + 'profile_update', + 'api_write', + ], + 'input' => env('FORUM_BOT_CAPTCHA_INPUT', ''), + 'message' => 'Complete the captcha challenge to continue.', + ], + + 'behavior' => [ + 'new_account_days' => 7, + 'rapid_post_window_minutes' => 1, + 'rapid_post_threshold' => 5, + 'rapid_thread_threshold' => 2, + 'recent_action_window_seconds' => 45, + 'recent_action_threshold' => 6, + 'login_attempt_window_minutes' => 10, + 'login_attempt_threshold' => 8, + 'profile_update_threshold' => 6, + 'profile_update_window_minutes' => 60, + 'api_request_window_minutes' => 1, + 'api_request_threshold' => 100, + 'repeated_content_penalty' => 50, + 'new_account_links_penalty' => 30, + 'rapid_post_penalty' => 40, + 'recent_action_penalty' => 40, + 'login_burst_penalty' => 35, + 'profile_burst_penalty' => 20, + 'api_burst_penalty' => 60, + ], + + 'account_farm' => [ + 'window_minutes' => 10, + 'register_attempt_threshold' => 10, + 'same_ip_users_threshold' => 5, + 'same_fingerprint_users_threshold' => 3, + 'same_pattern_users_threshold' => 3, + 'register_attempt_penalty' => 50, + 'same_ip_penalty' => 35, + 'same_fingerprint_penalty' => 40, + 'same_pattern_penalty' => 45, + ], + + 'ip' => [ + 'cache_ttl_minutes' => 15, + 'recent_high_risk_window_hours' => 24, + 'recent_high_risk_threshold' => 3, + 'recent_high_risk_penalty' => 20, + 'known_proxy_penalty' => 20, + 'datacenter_penalty' => 25, + 'tor_penalty' => 40, + 'blacklist_penalty' => 100, + 'known_proxies' => [], + 'datacenter_ranges' => [], + 'provider_ranges' => [ + 'aws' => [], + 'azure' => [], + 'gcp' => [], + 'digitalocean' => [], + 'hetzner' => [], + 'ovh' => [], + ], + 'tor_exit_nodes' => [], + ], + + 'rate_limits' => [ + 'penalties' => [ + 'default' => 35, + 'minute' => 35, + 'hour' => 45, + ], + ], + + 'geo_behavior' => [ + 'enabled' => true, + 'login_actions' => ['login'], + 'country_headers' => [ + 'CF-IPCountry', + 'CloudFront-Viewer-Country', + 'X-Country-Code', + 'X-App-Country-Code', + ], + 'recent_login_window_minutes' => 60, + 'country_change_penalty' => 50, + ], + + 'patterns' => [ + 'seo' => [ + 'best seo service', + 'cheap backlinks', + 'guaranteed traffic', + 'rank your website', + ], + 'casino' => [ + 'online casino', + 'jackpot bonus', + 'slot machine', + 'betting tips', + ], + 'crypto' => [ + 'crypto signal', + 'double your bitcoin', + 'guaranteed profit', + 'token presale', + ], + 'affiliate' => [ + 'affiliate link', + 'promo code', + 'limited offer', + 'work from home', + ], + 'repeated_phrase_penalty' => 40, + 'category_penalty' => 30, + ], + + 'scan' => [ + 'lookback_minutes' => 5, + 'auto_blacklist_attempts' => 10, + 'auto_blacklist_risk' => 80, + 'auto_blacklist_reason' => 'Automatically blacklisted by bot activity monitor.', + 'queue' => env('FORUM_BOT_SCAN_QUEUE', 'forum-moderation'), + ], +]; diff --git a/config/forum_security.php b/config/forum_security.php new file mode 100644 index 00000000..03b88c96 --- /dev/null +++ b/config/forum_security.php @@ -0,0 +1,65 @@ + env('FORUM_SECURITY_ENABLED', true), + + 'thresholds' => [ + 'safe' => 20, + 'log' => 20, + 'captcha' => 40, + 'moderate' => 60, + 'block' => 80, + 'firewall_block' => 70, + ], + + 'queues' => [ + 'moderation' => env('FORUM_SECURITY_MODERATION_QUEUE', 'forum-moderation'), + 'firewall' => env('FORUM_SECURITY_FIREWALL_QUEUE', 'forum-security'), + ], + + 'firewall' => [ + 'enabled' => true, + 'request_pattern' => [ + 'window_seconds' => 60, + 'burst_threshold' => 15, + 'burst_penalty' => 25, + 'missing_user_agent_penalty' => 10, + 'suspicious_path_penalty' => 20, + 'repeat_route_penalty' => 20, + ], + 'spam_wave' => [ + 'window_minutes' => 15, + 'same_hash_threshold' => 3, + 'same_hash_penalty' => 30, + 'same_ip_flagged_threshold' => 4, + 'same_ip_flagged_penalty' => 25, + 'same_signature_threshold' => 3, + 'same_signature_penalty' => 20, + ], + 'thread_attack' => [ + 'window_minutes' => 10, + 'topic_threshold' => 4, + 'reply_threshold' => 8, + 'topic_penalty' => 25, + 'reply_penalty' => 20, + ], + 'login_attack' => [ + 'window_minutes' => 15, + 'login_threshold' => 10, + 'register_threshold' => 6, + 'login_penalty' => 30, + 'register_penalty' => 35, + ], + 'scan' => [ + 'lookback_minutes' => 15, + 'auto_blacklist_attempts' => 4, + 'auto_blacklist_risk' => 70, + 'auto_blacklist_reason' => 'Automatically blacklisted by forum firewall activity monitor.', + ], + ], + + 'logging' => [ + 'store_request_payload' => false, + 'reason_limit' => 8, + ], +]; \ No newline at end of file diff --git a/config/services.php b/config/services.php index edd4a86f..b2c15a0d 100644 --- a/config/services.php +++ b/config/services.php @@ -43,13 +43,24 @@ return [ 'enabled' => env('RECAPTCHA_ENABLED', false), 'site_key' => env('RECAPTCHA_SITE_KEY'), 'secret' => env('RECAPTCHA_SECRET_KEY'), + 'script_url' => env('RECAPTCHA_SCRIPT_URL', 'https://www.google.com/recaptcha/api.js'), 'verify_url' => env('RECAPTCHA_VERIFY_URL', 'https://www.google.com/recaptcha/api/siteverify'), 'timeout' => (int) env('RECAPTCHA_TIMEOUT', 5), ], + 'hcaptcha' => [ + 'enabled' => env('HCAPTCHA_ENABLED', false), + 'site_key' => env('HCAPTCHA_SITE_KEY'), + 'secret' => env('HCAPTCHA_SECRET_KEY'), + 'script_url' => env('HCAPTCHA_SCRIPT_URL', 'https://js.hcaptcha.com/1/api.js'), + 'verify_url' => env('HCAPTCHA_VERIFY_URL', 'https://hcaptcha.com/siteverify'), + 'timeout' => (int) env('HCAPTCHA_TIMEOUT', 5), + ], + 'turnstile' => [ 'site_key' => env('TURNSTILE_SITE_KEY'), 'secret_key' => env('TURNSTILE_SECRET_KEY'), + 'script_url' => env('TURNSTILE_SCRIPT_URL', 'https://challenges.cloudflare.com/turnstile/v0/api.js'), 'verify_url' => env('TURNSTILE_VERIFY_URL', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'), 'timeout' => (int) env('TURNSTILE_TIMEOUT', 5), ], diff --git a/config/skinbase_ai_moderation.php b/config/skinbase_ai_moderation.php new file mode 100644 index 00000000..a3b61a86 --- /dev/null +++ b/config/skinbase_ai_moderation.php @@ -0,0 +1,132 @@ + env('SKINBASE_AI_MODERATION_ENABLED', true), + + 'provider' => env('SKINBASE_AI_MODERATION_PROVIDER', 'openai'), + + 'queue' => [ + 'name' => env('SKINBASE_AI_MODERATION_QUEUE', 'forum-moderation'), + ], + + 'preflight' => [ + 'run_ai_sync' => (bool) env('SKINBASE_AI_PREFLIGHT_SYNC', true), + ], + + 'thresholds' => [ + 'safe' => 20, + 'low_quality' => 40, + 'suspicious' => 60, + 'block' => 80, + ], + + 'behavior' => [ + 'new_account_days' => 7, + 'rapid_post_window_minutes' => 2, + 'rapid_post_threshold' => 5, + 'same_ip_window_days' => 7, + 'same_ip_accounts_threshold' => 2, + 'repeat_content_penalty' => 40, + 'new_account_with_links_penalty' => 30, + 'rapid_post_penalty' => 20, + 'same_ip_penalty' => 25, + 'high_link_frequency_penalty' => 10, + 'flagged_history_penalty' => 15, + ], + + 'links' => [ + 'too_many_links_penalty' => 15, + 'suspicious_domain_penalty' => 40, + 'shortener_penalty' => 10, + 'suspicious_tld_penalty' => 15, + 'too_many_links_threshold' => 3, + 'shorteners' => [ + 'bit.ly', + 'tinyurl.com', + 'goo.gl', + 't.co', + 'ow.ly', + 'cutt.ly', + 'rebrand.ly', + ], + 'suspicious_tlds' => [ + 'xyz', + 'top', + 'click', + 'loan', + 'work', + 'gq', + 'ml', + 'tk', + ], + ], + + 'trust' => [ + 'high' => 80, + 'medium' => 50, + 'high_modifier' => -15, + 'medium_modifier' => -8, + 'low_modifier' => 0, + 'flagged_ratio_penalty' => 10, + ], + + 'learning' => [ + 'spam_penalty' => 40, + 'safe_modifier' => -12, + 'max_spam_penalty' => 60, + 'max_safe_modifier' => -20, + ], + + 'scan' => [ + 'limit' => 200, + 'stale_after_minutes' => 10, + ], + + 'privacy' => [ + 'redact_emails' => true, + 'redact_ip_addresses' => true, + 'redact_mentions' => false, + ], + + 'heuristics' => [ + 'promotional_phrases' => [ + 'buy now', + 'limited offer', + 'cheap seo', + 'guaranteed traffic', + 'visit my profile', + 'work from home', + 'crypto signal', + 'telegram me', + 'whatsapp me', + 'dm for service', + ], + 'toxic_phrases' => [ + 'kill yourself', + 'you idiot', + 'piece of trash', + 'hate you', + 'worthless', + ], + ], + + 'providers' => [ + 'openai' => [ + 'api_key' => env('OPENAI_API_KEY'), + 'base_url' => env('OPENAI_BASE_URL', 'https://api.openai.com/v1'), + 'model' => env('SKINBASE_AI_OPENAI_MODEL', 'gpt-4.1-mini'), + 'timeout' => (int) env('SKINBASE_AI_OPENAI_TIMEOUT', 5), + ], + 'perspective_api' => [ + 'api_key' => env('PERSPECTIVE_API_KEY'), + 'base_url' => env('PERSPECTIVE_API_BASE_URL', 'https://commentanalyzer.googleapis.com/v1alpha1/comments:analyze'), + 'timeout' => (int) env('SKINBASE_AI_PERSPECTIVE_TIMEOUT', 5), + ], + 'local_llm' => [ + 'endpoint' => env('SKINBASE_AI_LOCAL_LLM_ENDPOINT'), + 'model' => env('SKINBASE_AI_LOCAL_LLM_MODEL', 'moderation'), + 'timeout' => (int) env('SKINBASE_AI_LOCAL_LLM_TIMEOUT', 5), + 'token' => env('SKINBASE_AI_LOCAL_LLM_TOKEN'), + ], + ], +]; diff --git a/deploy/supervisor/skinbase-queue.conf b/deploy/supervisor/skinbase-queue.conf index b9f9e868..d04206d3 100644 --- a/deploy/supervisor/skinbase-queue.conf +++ b/deploy/supervisor/skinbase-queue.conf @@ -1,5 +1,5 @@ [program:skinbase-queue] -command=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=default +command=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=forum-security,forum-moderation,default process_name=%(program_name)s_%(process_num)02d numprocs=1 autostart=true diff --git a/deploy/systemd/skinbase-queue.service b/deploy/systemd/skinbase-queue.service index 3dadbbd8..dd91e233 100644 --- a/deploy/systemd/skinbase-queue.service +++ b/deploy/systemd/skinbase-queue.service @@ -8,7 +8,7 @@ Group=www-data Restart=always RestartSec=3 WorkingDirectory=/var/www/skinbase -ExecStart=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=default +ExecStart=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=forum-security,forum-moderation,default StandardOutput=syslog StandardError=syslog SyslogIdentifier=skinbase-queue diff --git a/docs/forum-bot-protection.md b/docs/forum-bot-protection.md new file mode 100644 index 00000000..35426d14 --- /dev/null +++ b/docs/forum-bot-protection.md @@ -0,0 +1,208 @@ +# Forum Bot Protection + +This document describes the production anti-bot stack protecting forum, auth, profile, and selected API write actions. + +## Scope + +Primary implementation lives in: + +- `config/forum_bot_protection.php` +- `packages/klevze/Plugins/Forum/Services/Security` +- `app/Http/Middleware/ForumBotProtectionMiddleware.php` +- `packages/klevze/Plugins/Forum/Console/ForumBotScanCommand.php` +- `packages/klevze/Plugins/Forum/Jobs/BotActivityMonitor.php` + +Protected actions currently include: + +- registration +- login +- forum topic create +- forum reply create +- forum post update +- profile update +- selected API write routes + +## Detection Layers + +Risk scoring combines multiple signals: + +- honeypot hits +- browser and device fingerprints +- repeated content and spam phrase analysis +- account age and action burst behavior +- proxy, Tor, and blacklist checks +- provider and datacenter CIDR range checks +- account farm heuristics across IP and fingerprint reuse + +The score is interpreted through `config/forum_bot_protection.php`: + +- `allow` +- `log` +- `captcha` +- `moderate` +- `block` + +## Persistence + +Bot activity is stored in: + +- `forum_bot_logs` +- `forum_bot_ip_blacklist` +- `forum_bot_device_fingerprints` +- `forum_bot_behavior_profiles` + +User records also carry: + +- `bot_risk_score` +- `bot_flags` +- `last_bot_activity_at` + +## Captcha Escalation + +When a request risk score reaches the configured captcha threshold, middleware requires a provider-backed challenge before allowing the action. + +Provider selection: + +- `FORUM_BOT_CAPTCHA_PROVIDER=turnstile` +- `FORUM_BOT_CAPTCHA_PROVIDER=recaptcha` +- `FORUM_BOT_CAPTCHA_PROVIDER=hcaptcha` + +Optional request input override: + +- `FORUM_BOT_CAPTCHA_INPUT` + +Supported provider environment keys: + +### Turnstile + +- `TURNSTILE_SITE_KEY` +- `TURNSTILE_SECRET_KEY` +- `TURNSTILE_SCRIPT_URL` +- `TURNSTILE_VERIFY_URL` + +### reCAPTCHA + +- `RECAPTCHA_ENABLED` +- `RECAPTCHA_SITE_KEY` +- `RECAPTCHA_SECRET_KEY` +- `RECAPTCHA_SCRIPT_URL` +- `RECAPTCHA_VERIFY_URL` + +### hCaptcha + +- `HCAPTCHA_ENABLED` +- `HCAPTCHA_SITE_KEY` +- `HCAPTCHA_SECRET_KEY` +- `HCAPTCHA_SCRIPT_URL` +- `HCAPTCHA_VERIFY_URL` + +If the selected provider is missing required keys, captcha escalation is effectively disabled and high-risk requests will continue through the non-captcha anti-bot path. + +## Origin Header Setup + +Geo-behavior scoring only activates when the origin receives a trusted two-letter country header. The current analyzer checks these headers in order: + +- `CF-IPCountry` +- `CloudFront-Viewer-Country` +- `X-Country-Code` +- `X-App-Country-Code` + +Recommended production setup: + +### Cloudflare + +- If you only need country detection: Cloudflare Dashboard → `Network` → turn `IP Geolocation` on. +- If you want the broader location header set: Cloudflare Dashboard → `Rules` → `Managed Transforms` → enable `Add visitor location headers`. +- The origin header used by this app is `CF-IPCountry`. + +### Amazon CloudFront + +- Edit the distribution behavior used for the app origin. +- Attach an origin request policy that includes geolocation headers, or create a custom origin request policy that forwards `CloudFront-Viewer-Country`. +- If you cache on that behavior and want cache variation by forwarded headers, ensure the paired cache policy is compatible with the origin request policy you choose. + +### Reverse Proxy / Load Balancer + +- Pass the CDN country header through unchanged to PHP-FPM / Laravel. +- For Nginx, avoid clearing the header and explicitly preserve it if you normalize upstream headers, for example: `proxy_set_header CF-IPCountry $http_cf_ipcountry;` or `proxy_set_header CloudFront-Viewer-Country $http_cloudfront_viewer_country;`. +- If you terminate the CDN header at the proxy and want a normalized application header instead, map it to `X-Country-Code` and keep the value as a two-letter ISO country code. + +Validation: + +- Send a request through the real edge and confirm the header is visible in Laravel request headers. +- Check that a login event stored in `forum_bot_logs.metadata.country_code` contains the expected country code. + +## IP Range Configuration + +IP reputation supports three types of network lists in `config/forum_bot_protection.php`: + +- `known_proxies`: exact IPs or CIDRs for proxy and VPN ranges +- `datacenter_ranges`: generic datacenter or hosting CIDRs +- `provider_ranges`: provider-specific buckets such as `aws`, `azure`, `gcp`, `digitalocean`, `hetzner`, and `ovh` + +All three lists accept either exact IP strings or CIDR notation. + +Example: + +```php +'ip' => [ + 'known_proxies' => ['198.51.100.0/24'], + 'datacenter_ranges' => ['203.0.113.0/24'], + 'provider_ranges' => [ + 'aws' => ['54.240.0.0/12'], + 'hetzner' => ['88.198.0.0/16'], + ], +], +``` + +Operational guidance: + +- keep provider ranges in the named `provider_ranges` buckets so the control panel can show per-provider coverage counts +- populate ranges only from provider-owned feeds or other trusted sources you maintain internally +- after changing CIDR lists, clear cache if you need immediate effect on hot IPs + +## Queue and Scheduling + +Recent activity scanning runs through: + +- command: `php artisan forum:bot-scan` +- queued job: `BotActivityMonitor` +- schedule: every 5 minutes in `routes/console.php` + +Default command behavior dispatches the monitor job onto the configured queue. Use `--sync` for inline execution. + +## Admin Operations + +Control panel screen: + +- route: `admin.forum.security.bot-protection.main` + +Available actions: + +- review recent bot events +- inspect suspicious users +- inspect high-risk fingerprints +- inspect recent rate-limit violations and their limiter metadata +- manually blacklist IPs +- approve or ban flagged users +- confirm current captcha provider, threshold, and required env keys +- confirm configured proxy, datacenter, tor, and provider CIDR coverage counts +- filter analytics by time window and action +- export recent bot events as CSV +- export top bot reasons as JSON + +## Validation Checklist + +Useful commands: + +- `php artisan forum:bot-scan --help` +- `php artisan forum:bot-scan --sync --minutes=5` +- `php artisan route:list --name=admin.forum.security.bot-protection.main` +- `npm run build` + +Quick runtime checks: + +- confirm new bot events land in `forum_bot_logs` +- confirm fingerprints land in `forum_bot_device_fingerprints` +- confirm the jobs table contains `BotActivityMonitor` after `forum:bot-scan` +- confirm the control panel shows the expected captcha provider and action list diff --git a/resources/js/Pages/Settings/ProfileEdit.jsx b/resources/js/Pages/Settings/ProfileEdit.jsx index 54df88ec..fbbe3264 100644 --- a/resources/js/Pages/Settings/ProfileEdit.jsx +++ b/resources/js/Pages/Settings/ProfileEdit.jsx @@ -8,6 +8,8 @@ import Toggle from '../../components/ui/Toggle' import Select from '../../components/ui/Select' import Modal from '../../components/ui/Modal' import { RadioGroup } from '../../components/ui/Radio' +import { buildBotFingerprint } from '../../lib/security/botFingerprint' +import TurnstileField from '../../components/security/TurnstileField' const SETTINGS_SECTIONS = [ { key: 'profile', label: 'Profile', icon: 'fa-solid fa-user-astronaut', description: 'Public identity and avatar.' }, @@ -57,6 +59,16 @@ function getCsrfToken() { return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' } +async function botHeaders(extra = {}, captcha = {}) { + const fingerprint = await buildBotFingerprint() + + return { + ...extra, + 'X-Bot-Fingerprint': fingerprint, + ...(captcha?.token ? { 'X-Captcha-Token': captcha.token } : {}), + } +} + function toIsoDate(day, month, year) { if (!day || !month || !year) return '' return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` @@ -122,6 +134,8 @@ export default function ProfileEdit() { usernameCooldownDays = 30, usernameCooldownRemainingDays = 0, usernameCooldownActive = false, + captcha: initialCaptcha = {}, + flash = {}, } = props const fallbackDate = toIsoDate( @@ -194,6 +208,17 @@ export default function ProfileEdit() { notifications: {}, security: {}, }) + const [captchaState, setCaptchaState] = useState({ + required: !!flash?.botCaptchaRequired, + section: '', + token: '', + message: '', + nonce: 0, + provider: initialCaptcha?.provider || '', + siteKey: initialCaptcha?.siteKey || '', + inputName: initialCaptcha?.inputName || 'cf-turnstile-response', + scriptUrl: initialCaptcha?.scriptUrl || '', + }) const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl || '') const [avatarFile, setAvatarFile] = useState(null) @@ -346,6 +371,92 @@ export default function ProfileEdit() { setErrorsBySection((prev) => ({ ...prev, [section]: {} })) } + const resetCaptchaState = () => { + setCaptchaState((prev) => ({ + ...prev, + required: false, + section: '', + token: '', + message: '', + nonce: prev.nonce + 1, + })) + } + + const captureCaptchaRequirement = (section, payload = {}) => { + const requiresCaptcha = !!(payload?.requires_captcha || payload?.requiresCaptcha) + + if (!requiresCaptcha) { + return false + } + + const nextCaptcha = payload?.captcha || {} + const message = payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.' + + setCaptchaState((prev) => ({ + required: true, + section, + token: '', + message, + nonce: prev.nonce + 1, + provider: nextCaptcha.provider || payload?.captcha_provider || prev.provider || initialCaptcha?.provider || 'turnstile', + siteKey: nextCaptcha.siteKey || payload?.captcha_site_key || prev.siteKey || initialCaptcha?.siteKey || '', + inputName: nextCaptcha.inputName || payload?.captcha_input || prev.inputName || initialCaptcha?.inputName || 'cf-turnstile-response', + scriptUrl: nextCaptcha.scriptUrl || payload?.captcha_script_url || prev.scriptUrl || initialCaptcha?.scriptUrl || '', + })) + + updateSectionErrors(section, { + _general: [message], + captcha: [message], + }) + + return true + } + + const applyCaptchaPayload = (payload = {}) => { + if (!captchaState.required || !captchaState.inputName) { + return payload + } + + return { + ...payload, + [captchaState.inputName]: captchaState.token || '', + } + } + + const applyCaptchaFormData = (formData) => { + if (captchaState.required && captchaState.inputName) { + formData.set(captchaState.inputName, captchaState.token || '') + } + } + + const renderCaptchaChallenge = (section, placement = 'section') => { + if (!captchaState.required || !captchaState.siteKey || activeSection !== section) { + return null + } + + if (section === 'account' && showEmailChangeModal && placement !== 'modal') { + return null + } + + if (section === 'account' && !showEmailChangeModal && placement === 'modal') { + return null + } + + return ( +
{captchaState.message || 'Complete the captcha challenge to continue.'}
+You can change your username once every {usernameCooldownDays} days.
+ + {renderCaptchaChallenge('account')} ) : null} @@ -1034,6 +1181,8 @@ export default function ProfileEdit() { error={errorsBySection.personal.country?.[0]} /> )} + + {renderCaptchaChallenge('personal')} @@ -1085,6 +1234,8 @@ export default function ProfileEdit() { /> ))} + + {renderCaptchaChallenge('notifications')} @@ -1152,6 +1303,8 @@ export default function ProfileEdit() {