feat: add captcha-backed forum security hardening

This commit is contained in:
2026-03-17 16:06:28 +01:00
parent 980a15f66e
commit b3fc889452
40 changed files with 2849 additions and 108 deletions

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest; use App\Http\Requests\Auth\LoginRequest;
use App\Services\Security\CaptchaVerifier;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -14,9 +15,17 @@ class AuthenticatedSessionController extends Controller
/** /**
* Display the login view. * Display the login view.
*/ */
public function __construct(
private readonly CaptchaVerifier $captchaVerifier,
) {
}
public function create(): View public function create(): View
{ {
return view('auth.login'); return view('auth.login', [
'requiresCaptcha' => session('bot_captcha_required', false),
'captcha' => $this->captchaVerifier->frontendConfig(),
]);
} }
/** /**

View File

@@ -8,7 +8,7 @@ use App\Models\EmailSendEvent;
use App\Models\User; use App\Models\User;
use App\Services\Auth\DisposableEmailService; use App\Services\Auth\DisposableEmailService;
use App\Services\Auth\RegistrationVerificationTokenService; use App\Services\Auth\RegistrationVerificationTokenService;
use App\Services\Security\TurnstileVerifier; use App\Services\Security\CaptchaVerifier;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -19,7 +19,7 @@ use Illuminate\View\View;
class RegisteredUserController extends Controller class RegisteredUserController extends Controller
{ {
public function __construct( public function __construct(
private readonly TurnstileVerifier $turnstileVerifier, private readonly CaptchaVerifier $captchaVerifier,
private readonly DisposableEmailService $disposableEmailService, private readonly DisposableEmailService $disposableEmailService,
private readonly RegistrationVerificationTokenService $verificationTokenService, private readonly RegistrationVerificationTokenService $verificationTokenService,
) )
@@ -33,8 +33,8 @@ class RegisteredUserController extends Controller
{ {
return view('auth.register', [ return view('auth.register', [
'prefillEmail' => (string) $request->query('email', ''), 'prefillEmail' => (string) $request->query('email', ''),
'requiresTurnstile' => $this->shouldRequireTurnstile($request->ip()), 'requiresCaptcha' => $this->shouldRequireCaptcha($request->ip()),
'turnstileSiteKey' => (string) config('services.turnstile.site_key', ''), 'captcha' => $this->captchaVerifier->frontendConfig(),
]); ]);
} }
@@ -56,20 +56,22 @@ class RegisteredUserController extends Controller
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
$validated = $request->validate([ $rules = [
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'], 'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
'website' => ['nullable', 'max:0'], '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'])); $email = strtolower(trim((string) $validated['email']));
$ip = $request->ip(); $ip = $request->ip();
$this->trackRegisterAttempt($ip); $this->trackRegisterAttempt($ip);
if ($this->shouldRequireTurnstile($ip)) { if ($this->shouldRequireCaptcha($ip)) {
$verified = $this->turnstileVerifier->verify( $verified = $this->captchaVerifier->verify(
(string) $request->input('cf-turnstile-response', ''), (string) $request->input($this->captchaVerifier->inputName(), ''),
$ip $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; return false;
} }

View File

@@ -17,6 +17,7 @@ use App\Models\Artwork;
use App\Models\ProfileComment; use App\Models\ProfileComment;
use App\Models\Story; use App\Models\Story;
use App\Models\User; use App\Models\User;
use App\Services\Security\CaptchaVerifier;
use App\Services\AvatarService; use App\Services\AvatarService;
use App\Services\ArtworkService; use App\Services\ArtworkService;
use App\Services\FollowService; use App\Services\FollowService;
@@ -47,6 +48,7 @@ class ProfileController extends Controller
private readonly UsernameApprovalService $usernameApprovalService, private readonly UsernameApprovalService $usernameApprovalService,
private readonly FollowService $followService, private readonly FollowService $followService,
private readonly UserStatsService $userStats, private readonly UserStatsService $userStats,
private readonly CaptchaVerifier $captchaVerifier,
) )
{ {
} }
@@ -240,7 +242,9 @@ class ProfileController extends Controller
'flash' => [ 'flash' => [
'status' => session('status'), 'status' => session('status'),
'error' => session('error'), 'error' => session('error'),
'botCaptchaRequired' => session('bot_captcha_required', false),
], ],
'captcha' => $this->captchaVerifier->frontendConfig(),
])->rootView('settings'); ])->rootView('settings');
} }

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Middleware;
use Closure;
use cPad\Plugins\Forum\Services\AI\AIContentModerator;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ForumAIModerationMiddleware
{
public function __construct(
private readonly AIContentModerator $aiContentModerator,
) {
}
public function handle(Request $request, Closure $next, string $action = 'generic'): Response
{
$assessment = [
'action' => $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);
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Http\Middleware;
use App\Services\Security\CaptchaVerifier;
use cPad\Plugins\Forum\Services\Security\BotProtectionService;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
class ForumBotProtectionMiddleware
{
public function __construct(
private readonly BotProtectionService $botProtectionService,
private readonly CaptchaVerifier $captchaVerifier,
) {
}
public function handle(Request $request, Closure $next, string $action = 'generic'): Response|RedirectResponse|JsonResponse
{
$assessment = $this->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);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Middleware;
use Closure;
use cPad\Plugins\Forum\Services\Security\BotProtectionService;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Http\Request;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Symfony\Component\HttpFoundation\Response;
class ForumRateLimitMiddleware
{
public function __construct(
private readonly ThrottleRequests $throttleRequests,
private readonly BotProtectionService $botProtectionService,
) {
}
public function handle(Request $request, Closure $next): Response
{
$routeName = (string) optional($request->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,
};
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Http\Middleware;
use App\Services\Security\CaptchaVerifier;
use Closure;
use cPad\Plugins\Forum\Services\Security\ForumSecurityFirewallService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
class ForumSecurityFirewallMiddleware
{
public function __construct(
private readonly ForumSecurityFirewallService $firewallService,
private readonly CaptchaVerifier $captchaVerifier,
) {
}
public function handle(Request $request, Closure $next, string $action = 'generic'): Response|RedirectResponse|JsonResponse
{
$assessment = $this->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);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Middleware;
use Closure;
use cPad\Plugins\Forum\Services\ForumSpamDetector;
use cPad\Plugins\Forum\Services\LinkAnalyzer;
use cPad\Plugins\Forum\Services\Security\ContentPatternAnalyzer;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ForumSpamDetectionMiddleware
{
public function __construct(
private readonly ForumSpamDetector $spamDetector,
private readonly LinkAnalyzer $linkAnalyzer,
private readonly ContentPatternAnalyzer $contentPatternAnalyzer,
) {
}
public function handle(Request $request, Closure $next, string $action = 'generic'): Response
{
$title = trim((string) $request->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);
}
}

View File

@@ -18,15 +18,28 @@ class ForumPost extends Model
'id', 'id',
'thread_id', 'thread_id',
'topic_id', 'topic_id',
'source_ip_hash',
'user_id', 'user_id',
'content', 'content',
'content_hash',
'is_edited', 'is_edited',
'edited_at', 'edited_at',
'spam_score', 'spam_score',
'quality_score', 'quality_score',
'ai_spam_score',
'ai_toxicity_score',
'behavior_score',
'link_score',
'learning_score',
'risk_score',
'trust_modifier',
'flagged', 'flagged',
'flagged_reason', 'flagged_reason',
'moderation_checked', 'moderation_checked',
'moderation_status',
'moderation_labels',
'moderation_meta',
'last_ai_scan_at',
]; ];
public $incrementing = true; public $incrementing = true;
@@ -36,8 +49,18 @@ class ForumPost extends Model
'edited_at' => 'datetime', 'edited_at' => 'datetime',
'spam_score' => 'integer', 'spam_score' => 'integer',
'quality_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', 'flagged' => 'boolean',
'moderation_checked' => 'boolean', 'moderation_checked' => 'boolean',
'moderation_labels' => 'array',
'moderation_meta' => 'array',
'last_ai_scan_at' => 'datetime',
]; ];
public function thread(): BelongsTo public function thread(): BelongsTo

View File

@@ -44,6 +44,12 @@ class User extends Authenticatable
'cover_ext', 'cover_ext',
'cover_position', 'cover_position',
'trust_score', 'trust_score',
'bot_risk_score',
'bot_flags',
'last_bot_activity_at',
'spam_reports',
'approved_posts',
'flagged_posts',
'password', 'password',
'role', 'role',
'allow_messages_from', 'allow_messages_from',
@@ -76,6 +82,12 @@ class User extends Authenticatable
'deleted_at' => 'datetime', 'deleted_at' => 'datetime',
'cover_position' => 'integer', 'cover_position' => 'integer',
'trust_score' => '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', 'password' => 'hashed',
'allow_messages_from' => 'string', 'allow_messages_from' => 'string',
]; ];

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Services\Security\Captcha;
interface CaptchaProviderInterface
{
public function name(): string;
public function isEnabled(): bool;
public function siteKey(): string;
public function inputName(): string;
public function scriptUrl(): string;
public function verify(string $token, ?string $ip = null): bool;
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Services\Security\Captcha;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class HcaptchaCaptchaProvider implements CaptchaProviderInterface
{
public function name(): string
{
return 'hcaptcha';
}
public function isEnabled(): bool
{
return (bool) config('services.hcaptcha.enabled', false)
&& $this->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;
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Services\Security\Captcha;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class RecaptchaCaptchaProvider implements CaptchaProviderInterface
{
public function name(): string
{
return 'recaptcha';
}
public function isEnabled(): bool
{
return (bool) config('services.recaptcha.enabled', false)
&& $this->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;
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Services\Security\Captcha;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TurnstileCaptchaProvider implements CaptchaProviderInterface
{
public function name(): string
{
return 'turnstile';
}
public function isEnabled(): bool
{
return (bool) config('registration.enable_turnstile', true)
&& $this->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;
}
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Services\Security;
use App\Services\Security\Captcha\CaptchaProviderInterface;
use App\Services\Security\Captcha\HcaptchaCaptchaProvider;
use App\Services\Security\Captcha\RecaptchaCaptchaProvider;
use App\Services\Security\Captcha\TurnstileCaptchaProvider;
class CaptchaVerifier
{
public function __construct(
private readonly TurnstileCaptchaProvider $turnstileProvider,
private readonly RecaptchaCaptchaProvider $recaptchaProvider,
private readonly HcaptchaCaptchaProvider $hcaptchaProvider,
) {
}
public function provider(): string
{
$configured = strtolower(trim((string) config('forum_bot_protection.captcha.provider', 'turnstile')));
return match ($configured) {
'recaptcha' => '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,
};
}
}

View File

@@ -2,50 +2,21 @@
namespace App\Services\Security; namespace App\Services\Security;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TurnstileVerifier class TurnstileVerifier
{ {
public function __construct(
private readonly CaptchaVerifier $captchaVerifier,
) {
}
public function isEnabled(): bool public function isEnabled(): bool
{ {
return (bool) config('registration.enable_turnstile', true) return $this->captchaVerifier->provider() === 'turnstile'
&& (string) config('services.turnstile.site_key', '') !== '' && $this->captchaVerifier->isEnabled();
&& (string) config('services.turnstile.secret_key', '') !== '';
} }
public function verify(string $token, ?string $ip = null): bool public function verify(string $token, ?string $ip = null): bool
{ {
if (! $this->isEnabled()) { return $this->captchaVerifier->verify($token, $ip);
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;
}
} }
} }

View File

@@ -12,6 +12,11 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'chat_post',
'chat_post/*',
]);
$middleware->web(append: [ $middleware->web(append: [
\App\Http\Middleware\HandleInertiaRequests::class, \App\Http\Middleware\HandleInertiaRequests::class,
// Runs on every web request; no-ops for guests, redirects authenticated // 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, 'admin.moderation' => \App\Http\Middleware\EnsureAdminOrModerator::class,
'creator.access' => \App\Http\Middleware\EnsureCreatorAccess::class, 'creator.access' => \App\Http\Middleware\EnsureCreatorAccess::class,
'ensure.onboarding.complete'=> \App\Http\Middleware\EnsureOnboardingComplete::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, 'onboarding' => \App\Http\Middleware\EnsureOnboardingComplete::class,
'normalize.username' => \App\Http\Middleware\NormalizeUsername::class, 'normalize.username' => \App\Http\Middleware\NormalizeUsername::class,
]); ]);

View File

@@ -0,0 +1,147 @@
<?php
return [
'enabled' => 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'),
],
];

65
config/forum_security.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
return [
'enabled' => 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,
],
];

View File

@@ -43,13 +43,24 @@ return [
'enabled' => env('RECAPTCHA_ENABLED', false), 'enabled' => env('RECAPTCHA_ENABLED', false),
'site_key' => env('RECAPTCHA_SITE_KEY'), 'site_key' => env('RECAPTCHA_SITE_KEY'),
'secret' => env('RECAPTCHA_SECRET_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'), 'verify_url' => env('RECAPTCHA_VERIFY_URL', 'https://www.google.com/recaptcha/api/siteverify'),
'timeout' => (int) env('RECAPTCHA_TIMEOUT', 5), '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' => [ 'turnstile' => [
'site_key' => env('TURNSTILE_SITE_KEY'), 'site_key' => env('TURNSTILE_SITE_KEY'),
'secret_key' => env('TURNSTILE_SECRET_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'), 'verify_url' => env('TURNSTILE_VERIFY_URL', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'),
'timeout' => (int) env('TURNSTILE_TIMEOUT', 5), 'timeout' => (int) env('TURNSTILE_TIMEOUT', 5),
], ],

View File

@@ -0,0 +1,132 @@
<?php
return [
'enabled' => 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'),
],
],
];

View File

@@ -1,5 +1,5 @@
[program:skinbase-queue] [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 process_name=%(program_name)s_%(process_num)02d
numprocs=1 numprocs=1
autostart=true autostart=true

View File

@@ -8,7 +8,7 @@ Group=www-data
Restart=always Restart=always
RestartSec=3 RestartSec=3
WorkingDirectory=/var/www/skinbase 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 StandardOutput=syslog
StandardError=syslog StandardError=syslog
SyslogIdentifier=skinbase-queue SyslogIdentifier=skinbase-queue

View File

@@ -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

View File

@@ -8,6 +8,8 @@ import Toggle from '../../components/ui/Toggle'
import Select from '../../components/ui/Select' import Select from '../../components/ui/Select'
import Modal from '../../components/ui/Modal' import Modal from '../../components/ui/Modal'
import { RadioGroup } from '../../components/ui/Radio' import { RadioGroup } from '../../components/ui/Radio'
import { buildBotFingerprint } from '../../lib/security/botFingerprint'
import TurnstileField from '../../components/security/TurnstileField'
const SETTINGS_SECTIONS = [ const SETTINGS_SECTIONS = [
{ key: 'profile', label: 'Profile', icon: 'fa-solid fa-user-astronaut', description: 'Public identity and avatar.' }, { 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') || '' 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) { function toIsoDate(day, month, year) {
if (!day || !month || !year) return '' if (!day || !month || !year) return ''
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` 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, usernameCooldownDays = 30,
usernameCooldownRemainingDays = 0, usernameCooldownRemainingDays = 0,
usernameCooldownActive = false, usernameCooldownActive = false,
captcha: initialCaptcha = {},
flash = {},
} = props } = props
const fallbackDate = toIsoDate( const fallbackDate = toIsoDate(
@@ -194,6 +208,17 @@ export default function ProfileEdit() {
notifications: {}, notifications: {},
security: {}, 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 [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl || '')
const [avatarFile, setAvatarFile] = useState(null) const [avatarFile, setAvatarFile] = useState(null)
@@ -346,6 +371,92 @@ export default function ProfileEdit() {
setErrorsBySection((prev) => ({ ...prev, [section]: {} })) 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 (
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
<p className="mb-3 text-sm text-amber-100">{captchaState.message || 'Complete the captcha challenge to continue.'}</p>
<TurnstileField
key={`${section}-${placement}-${captchaState.nonce}`}
provider={captchaState.provider}
siteKey={captchaState.siteKey}
scriptUrl={captchaState.scriptUrl}
onToken={(token) => setCaptchaState((prev) => ({ ...prev, token }))}
className="rounded-lg border border-white/10 bg-black/20 p-3"
/>
</div>
)
}
const switchSection = (nextSection) => { const switchSection = (nextSection) => {
if (activeSection === nextSection) return if (activeSection === nextSection) return
if (dirtyMap[activeSection]) { if (dirtyMap[activeSection]) {
@@ -397,19 +508,23 @@ export default function ProfileEdit() {
if (avatarFile) { if (avatarFile) {
formData.append('avatar', avatarFile) formData.append('avatar', avatarFile)
} }
applyCaptchaFormData(formData)
const response = await fetch('/settings/profile/update', { const response = await fetch('/settings/profile/update', {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: await botHeaders({
Accept: 'application/json', Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(), 'X-CSRF-TOKEN': getCsrfToken(),
}, }, captchaState),
body: formData, body: formData,
}) })
const payload = await response.json().catch(() => ({})) const payload = await response.json().catch(() => ({}))
if (!response.ok) { if (!response.ok) {
if (captureCaptchaRequirement('profile', payload)) {
return
}
updateSectionErrors('profile', payload.errors || { _general: [payload.message || 'Unable to save profile section.'] }) updateSectionErrors('profile', payload.errors || { _general: [payload.message || 'Unable to save profile section.'] })
return return
} }
@@ -421,6 +536,7 @@ export default function ProfileEdit() {
setAvatarFile(null) setAvatarFile(null)
setAvatarPosition('center') setAvatarPosition('center')
setRemoveAvatar(false) setRemoveAvatar(false)
resetCaptchaState()
setSavedMessage({ section: 'profile', text: payload.message || 'Profile updated successfully.' }) setSavedMessage({ section: 'profile', text: payload.message || 'Profile updated successfully.' })
} catch (error) { } catch (error) {
updateSectionErrors('profile', { _general: ['Request failed. Please try again.'] }) updateSectionErrors('profile', { _general: ['Request failed. Please try again.'] })
@@ -446,21 +562,25 @@ export default function ProfileEdit() {
const response = await fetch('/settings/account/username', { const response = await fetch('/settings/account/username', {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: await botHeaders({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(), 'X-CSRF-TOKEN': getCsrfToken(),
}, }, captchaState),
body: JSON.stringify({ username: accountForm.username }), body: JSON.stringify(applyCaptchaPayload({ username: accountForm.username, homepage_url: '' })),
}) })
const payload = await response.json().catch(() => ({})) const payload = await response.json().catch(() => ({}))
if (!response.ok) { if (!response.ok) {
if (captureCaptchaRequirement('account', payload)) {
return
}
updateSectionErrors('account', payload.errors || { _general: [payload.message || 'Unable to save account section.'] }) updateSectionErrors('account', payload.errors || { _general: [payload.message || 'Unable to save account section.'] })
return return
} }
initialRef.current.accountForm = { ...accountForm } initialRef.current.accountForm = { ...accountForm }
resetCaptchaState()
setSavedMessage({ section: 'account', text: payload.message || 'Account updated successfully.' }) setSavedMessage({ section: 'account', text: payload.message || 'Account updated successfully.' })
} catch (error) { } catch (error) {
updateSectionErrors('account', { _general: ['Request failed. Please try again.'] }) updateSectionErrors('account', { _general: ['Request failed. Please try again.'] })
@@ -478,21 +598,26 @@ export default function ProfileEdit() {
const response = await fetch('/settings/email/request', { const response = await fetch('/settings/email/request', {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: await botHeaders({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(), 'X-CSRF-TOKEN': getCsrfToken(),
}, }, captchaState),
body: JSON.stringify({ new_email: emailChangeForm.new_email }), body: JSON.stringify(applyCaptchaPayload({ new_email: emailChangeForm.new_email, homepage_url: '' })),
}) })
const payload = await response.json().catch(() => ({})) const payload = await response.json().catch(() => ({}))
if (!response.ok) { if (!response.ok) {
if (captureCaptchaRequirement('account', payload)) {
setEmailChangeError(payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.')
return
}
setEmailChangeError(payload?.errors?.new_email?.[0] || payload?.message || 'Unable to request email change.') setEmailChangeError(payload?.errors?.new_email?.[0] || payload?.message || 'Unable to request email change.')
return return
} }
setEmailChangeStep('verify') setEmailChangeStep('verify')
resetCaptchaState()
setEmailChangeInfo(payload.message || 'Verification code sent to your new email address.') setEmailChangeInfo(payload.message || 'Verification code sent to your new email address.')
} catch (error) { } catch (error) {
setEmailChangeError('Request failed. Please try again.') setEmailChangeError('Request failed. Please try again.')
@@ -510,16 +635,20 @@ export default function ProfileEdit() {
const response = await fetch('/settings/email/verify', { const response = await fetch('/settings/email/verify', {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: await botHeaders({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(), 'X-CSRF-TOKEN': getCsrfToken(),
}, }, captchaState),
body: JSON.stringify({ code: emailChangeForm.code }), body: JSON.stringify(applyCaptchaPayload({ code: emailChangeForm.code, homepage_url: '' })),
}) })
const payload = await response.json().catch(() => ({})) const payload = await response.json().catch(() => ({}))
if (!response.ok) { if (!response.ok) {
if (captureCaptchaRequirement('account', payload)) {
setEmailChangeError(payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.')
return
}
setEmailChangeError(payload?.errors?.code?.[0] || payload?.message || 'Verification failed.') setEmailChangeError(payload?.errors?.code?.[0] || payload?.message || 'Verification failed.')
return return
} }
@@ -530,6 +659,7 @@ export default function ProfileEdit() {
setShowEmailChangeModal(false) setShowEmailChangeModal(false)
setEmailChangeStep('request') setEmailChangeStep('request')
setEmailChangeForm({ new_email: '', code: '' }) setEmailChangeForm({ new_email: '', code: '' })
resetCaptchaState()
setSavedMessage({ section: 'account', text: payload.message || 'Email updated successfully.' }) setSavedMessage({ section: 'account', text: payload.message || 'Email updated successfully.' })
} catch (error) { } catch (error) {
setEmailChangeError('Request failed. Please try again.') setEmailChangeError('Request failed. Please try again.')
@@ -547,25 +677,30 @@ export default function ProfileEdit() {
const response = await fetch('/settings/personal/update', { const response = await fetch('/settings/personal/update', {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: await botHeaders({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(), 'X-CSRF-TOKEN': getCsrfToken(),
}, }, captchaState),
body: JSON.stringify({ body: JSON.stringify(applyCaptchaPayload({
birthday: toIsoDate(personalForm.day, personalForm.month, personalForm.year) || null, birthday: toIsoDate(personalForm.day, personalForm.month, personalForm.year) || null,
gender: personalForm.gender || null, gender: personalForm.gender || null,
country: personalForm.country || null, country: personalForm.country || null,
}), homepage_url: '',
})),
}) })
const payload = await response.json().catch(() => ({})) const payload = await response.json().catch(() => ({}))
if (!response.ok) { if (!response.ok) {
if (captureCaptchaRequirement('personal', payload)) {
return
}
updateSectionErrors('personal', payload.errors || { _general: [payload.message || 'Unable to save personal details.'] }) updateSectionErrors('personal', payload.errors || { _general: [payload.message || 'Unable to save personal details.'] })
return return
} }
initialRef.current.personalForm = { ...personalForm } initialRef.current.personalForm = { ...personalForm }
resetCaptchaState()
setSavedMessage({ section: 'personal', text: payload.message || 'Personal details saved successfully.' }) setSavedMessage({ section: 'personal', text: payload.message || 'Personal details saved successfully.' })
} catch (error) { } catch (error) {
updateSectionErrors('personal', { _general: ['Request failed. Please try again.'] }) updateSectionErrors('personal', { _general: ['Request failed. Please try again.'] })
@@ -583,21 +718,25 @@ export default function ProfileEdit() {
const response = await fetch('/settings/notifications/update', { const response = await fetch('/settings/notifications/update', {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: await botHeaders({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(), 'X-CSRF-TOKEN': getCsrfToken(),
}, }, captchaState),
body: JSON.stringify(notificationForm), body: JSON.stringify(applyCaptchaPayload({ ...notificationForm, homepage_url: '' })),
}) })
const payload = await response.json().catch(() => ({})) const payload = await response.json().catch(() => ({}))
if (!response.ok) { if (!response.ok) {
if (captureCaptchaRequirement('notifications', payload)) {
return
}
updateSectionErrors('notifications', payload.errors || { _general: [payload.message || 'Unable to save notifications.'] }) updateSectionErrors('notifications', payload.errors || { _general: [payload.message || 'Unable to save notifications.'] })
return return
} }
initialRef.current.notificationForm = { ...notificationForm } initialRef.current.notificationForm = { ...notificationForm }
resetCaptchaState()
setSavedMessage({ section: 'notifications', text: payload.message || 'Notification settings saved successfully.' }) setSavedMessage({ section: 'notifications', text: payload.message || 'Notification settings saved successfully.' })
} catch (error) { } catch (error) {
updateSectionErrors('notifications', { _general: ['Request failed. Please try again.'] }) updateSectionErrors('notifications', { _general: ['Request failed. Please try again.'] })
@@ -615,16 +754,19 @@ export default function ProfileEdit() {
const response = await fetch('/settings/security/password', { const response = await fetch('/settings/security/password', {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: await botHeaders({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(), 'X-CSRF-TOKEN': getCsrfToken(),
}, }, captchaState),
body: JSON.stringify(securityForm), body: JSON.stringify(applyCaptchaPayload({ ...securityForm, homepage_url: '' })),
}) })
const payload = await response.json().catch(() => ({})) const payload = await response.json().catch(() => ({}))
if (!response.ok) { if (!response.ok) {
if (captureCaptchaRequirement('security', payload)) {
return
}
updateSectionErrors('security', payload.errors || { _general: [payload.message || 'Unable to update password.'] }) updateSectionErrors('security', payload.errors || { _general: [payload.message || 'Unable to update password.'] })
return return
} }
@@ -634,6 +776,7 @@ export default function ProfileEdit() {
new_password: '', new_password: '',
new_password_confirmation: '', new_password_confirmation: '',
}) })
resetCaptchaState()
setSavedMessage({ section: 'security', text: payload.message || 'Password updated successfully.' }) setSavedMessage({ section: 'security', text: payload.message || 'Password updated successfully.' })
} catch (error) { } catch (error) {
updateSectionErrors('security', { _general: ['Request failed. Please try again.'] }) updateSectionErrors('security', { _general: ['Request failed. Please try again.'] })
@@ -857,6 +1000,8 @@ export default function ProfileEdit() {
rows={3} rows={3}
error={errorsBySection.profile.description?.[0]} error={errorsBySection.profile.description?.[0]}
/> />
{renderCaptchaChallenge('profile')}
</div> </div>
</div> </div>
</SectionCard> </SectionCard>
@@ -933,6 +1078,8 @@ export default function ProfileEdit() {
<p className="mt-4 rounded-lg border border-white/10 bg-white/[0.02] px-3 py-2 text-xs text-slate-300"> <p className="mt-4 rounded-lg border border-white/10 bg-white/[0.02] px-3 py-2 text-xs text-slate-300">
You can change your username once every {usernameCooldownDays} days. You can change your username once every {usernameCooldownDays} days.
</p> </p>
{renderCaptchaChallenge('account')}
</SectionCard> </SectionCard>
</form> </form>
) : null} ) : null}
@@ -1034,6 +1181,8 @@ export default function ProfileEdit() {
error={errorsBySection.personal.country?.[0]} error={errorsBySection.personal.country?.[0]}
/> />
)} )}
{renderCaptchaChallenge('personal')}
</div> </div>
</SectionCard> </SectionCard>
</form> </form>
@@ -1085,6 +1234,8 @@ export default function ProfileEdit() {
/> />
</div> </div>
))} ))}
{renderCaptchaChallenge('notifications')}
</div> </div>
</SectionCard> </SectionCard>
</form> </form>
@@ -1152,6 +1303,8 @@ export default function ProfileEdit() {
<div className="rounded-lg border border-white/5 bg-white/[0.02] p-3 text-xs text-slate-400"> <div className="rounded-lg border border-white/5 bg-white/[0.02] p-3 text-xs text-slate-400">
Future security controls: Two-factor authentication, active sessions, and login history. Future security controls: Two-factor authentication, active sessions, and login history.
</div> </div>
{renderCaptchaChallenge('security')}
</div> </div>
</SectionCard> </SectionCard>
</form> </form>
@@ -1221,6 +1374,8 @@ export default function ProfileEdit() {
</div> </div>
) : null} ) : null}
{renderCaptchaChallenge('account', 'modal')}
{emailChangeStep === 'request' ? ( {emailChangeStep === 'request' ? (
<TextInput <TextInput
label="Enter new email address" label="Enter new email address"

View File

@@ -0,0 +1,165 @@
import React, { useEffect, useRef } from 'react'
const providerAdapters = {
turnstile: {
globalName: 'turnstile',
render(api, container, { siteKey, theme, onToken }) {
return api.render(container, {
sitekey: siteKey,
theme,
callback: (token) => onToken?.(token || ''),
'expired-callback': () => onToken?.(''),
'error-callback': () => onToken?.(''),
})
},
cleanup(api, widgetId, container, onToken) {
if (widgetId !== null && api?.remove) {
api.remove(widgetId)
}
if (container) {
container.innerHTML = ''
}
onToken?.('')
},
},
recaptcha: {
globalName: 'grecaptcha',
render(api, container, { siteKey, theme, onToken }) {
return api.render(container, {
sitekey: siteKey,
theme,
callback: (token) => onToken?.(token || ''),
'expired-callback': () => onToken?.(''),
'error-callback': () => onToken?.(''),
})
},
cleanup(api, widgetId, container, onToken) {
if (widgetId !== null && api?.reset) {
api.reset(widgetId)
}
if (container) {
container.innerHTML = ''
}
onToken?.('')
},
},
hcaptcha: {
globalName: 'hcaptcha',
render(api, container, { siteKey, theme, onToken }) {
return api.render(container, {
sitekey: siteKey,
theme,
callback: (token) => onToken?.(token || ''),
'expired-callback': () => onToken?.(''),
'error-callback': () => onToken?.(''),
})
},
cleanup(api, widgetId, container, onToken) {
if (widgetId !== null && api?.remove) {
api.remove(widgetId)
}
if (container) {
container.innerHTML = ''
}
onToken?.('')
},
},
}
function loadCaptchaScript(src) {
if (!src) {
return Promise.resolve()
}
if (!window.__skinbaseCaptchaScripts) {
window.__skinbaseCaptchaScripts = {}
}
if (!window.__skinbaseCaptchaScripts[src]) {
window.__skinbaseCaptchaScripts[src] = new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`)
if (existing) {
if (existing.dataset.loaded === 'true') {
resolve()
return
}
existing.addEventListener('load', () => resolve(), { once: true })
existing.addEventListener('error', () => reject(new Error(`Failed to load captcha script: ${src}`)), { once: true })
return
}
const script = document.createElement('script')
script.src = src
script.async = true
script.defer = true
script.addEventListener('load', () => {
script.dataset.loaded = 'true'
resolve()
}, { once: true })
script.addEventListener('error', () => reject(new Error(`Failed to load captcha script: ${src}`)), { once: true })
document.head.appendChild(script)
})
}
return window.__skinbaseCaptchaScripts[src]
}
export default function TurnstileField({ provider = 'turnstile', siteKey, scriptUrl = '', onToken, theme = 'dark', className = '' }) {
const containerRef = useRef(null)
const widgetIdRef = useRef(null)
useEffect(() => {
const adapter = providerAdapters[provider] || providerAdapters.turnstile
if (!siteKey || !containerRef.current) {
return undefined
}
let cancelled = false
let intervalId = null
const mountWidget = () => {
const api = window[adapter.globalName]
if (cancelled || !api?.render || widgetIdRef.current !== null) {
return
}
widgetIdRef.current = adapter.render(api, containerRef.current, {
siteKey,
theme,
onToken,
})
}
loadCaptchaScript(scriptUrl).catch(() => onToken?.('')).finally(() => {
const api = window[adapter.globalName]
if (typeof api?.ready === 'function') {
api.ready(mountWidget)
} else {
mountWidget()
}
if (widgetIdRef.current === null) {
intervalId = window.setInterval(mountWidget, 250)
}
})
return () => {
cancelled = true
if (intervalId) {
window.clearInterval(intervalId)
}
adapter.cleanup(window[adapter.globalName], widgetIdRef.current, containerRef.current, onToken)
widgetIdRef.current = null
}
}, [className, onToken, provider, scriptUrl, siteKey, theme])
if (!siteKey) {
return null
}
return <div ref={containerRef} className={className} />
}

View File

@@ -0,0 +1,65 @@
async function sha256Hex(value) {
if (!window.crypto?.subtle) {
return ''
}
const encoded = new TextEncoder().encode(value)
const digest = await window.crypto.subtle.digest('SHA-256', encoded)
return Array.from(new Uint8Array(digest))
.map((part) => part.toString(16).padStart(2, '0'))
.join('')
}
function readWebglVendor() {
try {
const canvas = document.createElement('canvas')
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl')
if (!gl) {
return 'no-webgl'
}
const extension = gl.getExtension('WEBGL_debug_renderer_info')
if (!extension) {
return 'webgl-hidden'
}
return [
gl.getParameter(extension.UNMASKED_VENDOR_WEBGL),
gl.getParameter(extension.UNMASKED_RENDERER_WEBGL),
].join(':')
} catch {
return 'webgl-error'
}
}
export async function buildBotFingerprint() {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'unknown'
const screenSize = typeof window.screen !== 'undefined'
? `${window.screen.width}x${window.screen.height}x${window.devicePixelRatio || 1}`
: 'no-screen'
const payload = [
navigator.userAgent || 'unknown-ua',
navigator.language || 'unknown-language',
navigator.platform || 'unknown-platform',
timezone,
screenSize,
readWebglVendor(),
].join('|')
return sha256Hex(payload)
}
export async function populateBotFingerprint(form) {
if (!form) {
return ''
}
const fingerprint = await buildBotFingerprint()
const field = form.querySelector('input[name="_bot_fingerprint"]')
if (field && fingerprint !== '') {
field.value = fingerprint
}
return fingerprint
}

View File

@@ -16,10 +16,23 @@
</div> </div>
@endif @endif
@if($errors->has('bot'))
<div class="rounded-lg bg-red-900/40 border border-red-500/40 px-4 py-3 text-sm text-red-300 mb-4">
{{ $errors->first('bot') }}
</div>
@endif
@include('auth.partials.social-login') @include('auth.partials.social-login')
<form method="POST" action="{{ route('login') }}" class="space-y-5"> <form method="POST" action="{{ route('login') }}" class="space-y-5" data-bot-form>
@csrf @csrf
<input type="text" name="homepage_url" value="" tabindex="-1" autocomplete="off" class="hidden" aria-hidden="true">
<input type="hidden" name="_bot_fingerprint" value="">
@php
$captchaProvider = $captcha['provider'] ?? 'turnstile';
$captchaSiteKey = $captcha['siteKey'] ?? '';
@endphp
<div> <div>
<label class="block text-sm mb-1 text-white/80" for="email">Email</label> <label class="block text-sm mb-1 text-white/80" for="email">Email</label>
@@ -33,6 +46,17 @@
<x-input-error :messages="$errors->get('password')" class="mt-2" /> <x-input-error :messages="$errors->get('password')" class="mt-2" />
</div> </div>
@if(($requiresCaptcha ?? false) && $captchaSiteKey !== '')
@if($captchaProvider === 'recaptcha')
<div class="g-recaptcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@elseif($captchaProvider === 'hcaptcha')
<div class="h-captcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@else
<div class="cf-turnstile" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@endif
<x-input-error :messages="$errors->get('captcha')" class="mt-2" />
@endif
<div class="flex items-center justify-between text-sm text-white/60"> <div class="flex items-center justify-between text-sm text-white/60">
<label class="flex items-center gap-2"> <label class="flex items-center gap-2">
<input type="checkbox" name="remember" class="rounded bg-slate-800 border-white/20" /> <input type="checkbox" name="remember" class="rounded bg-slate-800 border-white/20" />
@@ -51,4 +75,8 @@
</div> </div>
</div> </div>
</div> </div>
@if(($requiresCaptcha ?? false) && (($captcha['siteKey'] ?? '') !== '') && (($captcha['scriptUrl'] ?? '') !== ''))
<script src="{{ $captcha['scriptUrl'] }}" async defer></script>
@endif
@include('partials.bot-fingerprint-script')
@endsection @endsection

View File

@@ -13,9 +13,22 @@
</div> </div>
@endif @endif
@if($errors->has('bot'))
<div class="rounded-lg bg-red-900/40 border border-red-500/40 px-4 py-3 text-sm text-red-300 mb-4">
{{ $errors->first('bot') }}
</div>
@endif
@include('auth.partials.social-login', ['dividerLabel' => 'or register with email']) @include('auth.partials.social-login', ['dividerLabel' => 'or register with email'])
<form method="POST" action="{{ route('register') }}" class="space-y-5"> <form method="POST" action="{{ route('register') }}" class="space-y-5" data-bot-form>
@csrf @csrf
<input type="text" name="homepage_url" value="" tabindex="-1" autocomplete="off" class="hidden" aria-hidden="true">
<input type="hidden" name="_bot_fingerprint" value="">
@php
$captchaProvider = $captcha['provider'] ?? 'turnstile';
$captchaSiteKey = $captcha['siteKey'] ?? '';
@endphp
<div> <div>
<label class="block text-sm mb-1 text-white/80" for="email">Email</label> <label class="block text-sm mb-1 text-white/80" for="email">Email</label>
@@ -23,8 +36,14 @@
<x-input-error :messages="$errors->get('email')" class="mt-2" /> <x-input-error :messages="$errors->get('email')" class="mt-2" />
</div> </div>
@if(($requiresTurnstile ?? false) && ($turnstileSiteKey ?? '') !== '') @if((($requiresCaptcha ?? false) || session('bot_captcha_required')) && $captchaSiteKey !== '')
<div class="cf-turnstile" data-sitekey="{{ $turnstileSiteKey }}"></div> @if($captchaProvider === 'recaptcha')
<div class="g-recaptcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@elseif($captchaProvider === 'hcaptcha')
<div class="h-captcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@else
<div class="cf-turnstile" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@endif
<x-input-error :messages="$errors->get('captcha')" class="mt-2" /> <x-input-error :messages="$errors->get('captcha')" class="mt-2" />
@endif @endif
@@ -35,7 +54,8 @@
</div> </div>
</div> </div>
</div> </div>
@if(($requiresTurnstile ?? false) && ($turnstileSiteKey ?? '') !== '') @if((($requiresCaptcha ?? false) || session('bot_captcha_required')) && (($captcha['siteKey'] ?? '') !== '') && (($captcha['scriptUrl'] ?? '') !== ''))
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> <script src="{{ $captcha['scriptUrl'] }}" async defer></script>
@endif @endif
@include('partials.bot-fingerprint-script')
@endsection @endsection

View File

@@ -0,0 +1,55 @@
<script>
(() => {
const forms = document.querySelectorAll('[data-bot-form]');
if (!forms.length || !window.crypto?.subtle) {
return;
}
const readWebglVendor = () => {
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) {
return 'no-webgl';
}
const extension = gl.getExtension('WEBGL_debug_renderer_info');
if (!extension) {
return 'webgl-hidden';
}
return [
gl.getParameter(extension.UNMASKED_VENDOR_WEBGL),
gl.getParameter(extension.UNMASKED_RENDERER_WEBGL),
].join(':');
} catch {
return 'webgl-error';
}
};
const fingerprintPayload = [
navigator.userAgent || 'unknown-ua',
navigator.language || 'unknown-language',
navigator.platform || 'unknown-platform',
Intl.DateTimeFormat().resolvedOptions().timeZone || 'unknown-timezone',
`${window.screen?.width || 0}x${window.screen?.height || 0}x${window.devicePixelRatio || 1}`,
readWebglVendor(),
].join('|');
const encodeHex = (buffer) => Array.from(new Uint8Array(buffer))
.map((part) => part.toString(16).padStart(2, '0'))
.join('');
window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(fingerprintPayload))
.then((buffer) => {
const fingerprint = encodeHex(buffer);
forms.forEach((form) => {
const input = form.querySelector('input[name="_bot_fingerprint"]');
if (input) {
input.value = fingerprint;
}
});
})
.catch(() => {});
})();
</script>

View File

@@ -11,20 +11,20 @@ Route::middleware(['web', 'auth'])->prefix('dashboard')->name('api.dashboard.')-
}); });
Route::middleware(['web', 'auth', 'creator.access'])->prefix('stories')->name('api.stories.')->group(function () { Route::middleware(['web', 'auth', 'creator.access'])->prefix('stories')->name('api.stories.')->group(function () {
Route::post('create', [\App\Http\Controllers\StoryController::class, 'apiCreate'])->name('create'); Route::post('create', [\App\Http\Controllers\StoryController::class, 'apiCreate'])->middleware('forum.bot.protection:api_write')->name('create');
Route::put('update', [\App\Http\Controllers\StoryController::class, 'apiUpdate'])->name('update'); Route::put('update', [\App\Http\Controllers\StoryController::class, 'apiUpdate'])->middleware('forum.bot.protection:api_write')->name('update');
Route::post('autosave', [\App\Http\Controllers\StoryController::class, 'apiAutosave'])->name('autosave'); Route::post('autosave', [\App\Http\Controllers\StoryController::class, 'apiAutosave'])->middleware('forum.bot.protection:api_write')->name('autosave');
}); });
Route::middleware(['web', 'auth', 'creator.access'])->prefix('story')->name('api.story.')->group(function () { Route::middleware(['web', 'auth', 'creator.access'])->prefix('story')->name('api.story.')->group(function () {
Route::post('upload-image', [\App\Http\Controllers\StoryController::class, 'apiUploadImage'])->name('upload-image'); Route::post('upload-image', [\App\Http\Controllers\StoryController::class, 'apiUploadImage'])->middleware('forum.bot.protection:api_write')->name('upload-image');
Route::get('artworks', [\App\Http\Controllers\StoryController::class, 'apiArtworks'])->name('artworks'); Route::get('artworks', [\App\Http\Controllers\StoryController::class, 'apiArtworks'])->name('artworks');
}); });
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('profile/cover')->name('api.profile.cover.')->group(function () { Route::middleware(['web', 'auth', 'normalize.username'])->prefix('profile/cover')->name('api.profile.cover.')->group(function () {
Route::post('upload', [\App\Http\Controllers\User\ProfileCoverController::class, 'upload'])->middleware('throttle:20,1')->name('upload'); Route::post('upload', [\App\Http\Controllers\User\ProfileCoverController::class, 'upload'])->middleware(['throttle:20,1', 'forum.bot.protection:profile_update'])->name('upload');
Route::post('position', [\App\Http\Controllers\User\ProfileCoverController::class, 'updatePosition'])->middleware('throttle:30,1')->name('position'); Route::post('position', [\App\Http\Controllers\User\ProfileCoverController::class, 'updatePosition'])->middleware(['throttle:30,1', 'forum.bot.protection:profile_update'])->name('position');
Route::delete('/', [\App\Http\Controllers\User\ProfileCoverController::class, 'destroy'])->middleware('throttle:20,1')->name('destroy'); Route::delete('/', [\App\Http\Controllers\User\ProfileCoverController::class, 'destroy'])->middleware(['throttle:20,1', 'forum.bot.protection:profile_update'])->name('destroy');
}); });
// ── Per-artwork signal tracking (public) ──────────────────────────────────── // ── Per-artwork signal tracking (public) ────────────────────────────────────
@@ -38,14 +38,20 @@ Route::middleware(['web', 'throttle:300,1'])
Route::middleware(['web', 'throttle:5,10']) Route::middleware(['web', 'throttle:5,10'])
->post('art/{id}/view', \App\Http\Controllers\Api\ArtworkViewController::class) ->post('art/{id}/view', \App\Http\Controllers\Api\ArtworkViewController::class)
->middleware('forum.bot.protection:api_write')
->whereNumber('id') ->whereNumber('id')
->name('api.art.view'); ->name('api.art.view');
Route::middleware(['web', 'throttle:10,1']) Route::middleware(['web', 'throttle:10,1'])
->post('art/{id}/download', \App\Http\Controllers\Api\ArtworkDownloadController::class) ->post('art/{id}/download', \App\Http\Controllers\Api\ArtworkDownloadController::class)
->middleware('forum.bot.protection:api_write')
->whereNumber('id') ->whereNumber('id')
->name('api.art.download'); ->name('api.art.download');
Route::middleware(['web', 'throttle:reactions-read'])
->get('community/activity', [\App\Http\Controllers\Api\CommunityActivityController::class, 'index'])
->name('api.community.activity');
// ── Ranking lists (public, throttled, Redis-cached) ───────────────────────── // ── Ranking lists (public, throttled, Redis-cached) ─────────────────────────
// GET /api/rank/global?type=trending|new_hot|best // GET /api/rank/global?type=trending|new_hot|best
// GET /api/rank/category/{id}?type=trending|new_hot|best // GET /api/rank/category/{id}?type=trending|new_hot|best
@@ -136,24 +142,25 @@ Route::middleware(['throttle:60,1'])
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('artworks')->name('api.artworks.')->group(function () { Route::middleware(['web', 'auth', 'normalize.username'])->prefix('artworks')->name('api.artworks.')->group(function () {
Route::post('/', [\App\Http\Controllers\Api\ArtworkController::class, 'store']) Route::post('/', [\App\Http\Controllers\Api\ArtworkController::class, 'store'])
->middleware('forum.bot.protection:api_write')
->name('store'); ->name('store');
}); });
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('uploads')->name('api.uploads.')->group(function () { Route::middleware(['web', 'auth', 'normalize.username'])->prefix('uploads')->name('api.uploads.')->group(function () {
Route::post('init', [\App\Http\Controllers\Api\UploadController::class, 'init']) Route::post('init', [\App\Http\Controllers\Api\UploadController::class, 'init'])
->middleware('throttle:uploads-init') ->middleware(['throttle:uploads-init', 'forum.bot.protection:api_write'])
->name('init'); ->name('init');
Route::post('preload', [\App\Http\Controllers\Api\UploadController::class, 'preload']) Route::post('preload', [\App\Http\Controllers\Api\UploadController::class, 'preload'])
->middleware('throttle:uploads-init') ->middleware(['throttle:uploads-init', 'forum.bot.protection:api_write'])
->name('preload'); ->name('preload');
Route::post('{id}/autosave', [\App\Http\Controllers\Api\UploadController::class, 'autosave']) Route::post('{id}/autosave', [\App\Http\Controllers\Api\UploadController::class, 'autosave'])
->middleware('throttle:uploads-finish') ->middleware(['throttle:uploads-finish', 'forum.bot.protection:api_write'])
->name('autosave'); ->name('autosave');
Route::post('{id}/publish', [\App\Http\Controllers\Api\UploadController::class, 'publish']) Route::post('{id}/publish', [\App\Http\Controllers\Api\UploadController::class, 'publish'])
->middleware('throttle:uploads-finish') ->middleware(['throttle:uploads-finish', 'forum.bot.protection:api_write'])
->name('publish'); ->name('publish');
Route::get('{id}/status', [\App\Http\Controllers\Api\UploadController::class, 'processingStatus']) Route::get('{id}/status', [\App\Http\Controllers\Api\UploadController::class, 'processingStatus'])
@@ -161,15 +168,15 @@ Route::middleware(['web', 'auth', 'normalize.username'])->prefix('uploads')->nam
->name('processing-status'); ->name('processing-status');
Route::post('chunk', [\App\Http\Controllers\Api\UploadController::class, 'chunk']) Route::post('chunk', [\App\Http\Controllers\Api\UploadController::class, 'chunk'])
->middleware('throttle:uploads-init') ->middleware(['throttle:uploads-init', 'forum.bot.protection:api_write'])
->name('chunk'); ->name('chunk');
Route::post('finish', [\App\Http\Controllers\Api\UploadController::class, 'finish']) Route::post('finish', [\App\Http\Controllers\Api\UploadController::class, 'finish'])
->middleware('throttle:uploads-finish') ->middleware(['throttle:uploads-finish', 'forum.bot.protection:api_write'])
->name('finish'); ->name('finish');
Route::post('cancel', [\App\Http\Controllers\Api\UploadController::class, 'cancel']) Route::post('cancel', [\App\Http\Controllers\Api\UploadController::class, 'cancel'])
->middleware('throttle:uploads-finish') ->middleware(['throttle:uploads-finish', 'forum.bot.protection:api_write'])
->name('cancel'); ->name('cancel');
Route::get('status/{id}', [\App\Http\Controllers\Api\UploadController::class, 'status']) Route::get('status/{id}', [\App\Http\Controllers\Api\UploadController::class, 'status'])
@@ -204,6 +211,9 @@ Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/reports')-
Route::get('feed-performance', [\App\Http\Controllers\Api\Admin\FeedPerformanceReportController::class, 'index']) Route::get('feed-performance', [\App\Http\Controllers\Api\Admin\FeedPerformanceReportController::class, 'index'])
->name('feed-performance'); ->name('feed-performance');
Route::get('tags', [\App\Http\Controllers\Api\Admin\TagInteractionReportController::class, 'index'])
->name('tags');
}); });
Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/usernames')->name('api.admin.usernames.')->group(function () { Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/usernames')->name('api.admin.usernames.')->group(function () {
@@ -223,13 +233,17 @@ Route::post('analytics/similar-artworks', [\App\Http\Controllers\Api\SimilarArtw
->middleware('throttle:uploads-status') ->middleware('throttle:uploads-status')
->name('api.analytics.similar-artworks.store'); ->name('api.analytics.similar-artworks.store');
Route::middleware(['web'])->post('analytics/tags', [\App\Http\Controllers\Api\TagInteractionAnalyticsController::class, 'store'])
->middleware(['throttle:uploads-status', 'forum.bot.protection:api_write'])
->name('api.analytics.tags.store');
Route::middleware(['web', 'auth'])->post('analytics/feed', [\App\Http\Controllers\Api\FeedAnalyticsController::class, 'store']) Route::middleware(['web', 'auth'])->post('analytics/feed', [\App\Http\Controllers\Api\FeedAnalyticsController::class, 'store'])
->middleware('throttle:uploads-status') ->middleware(['throttle:uploads-status', 'forum.bot.protection:api_write'])
->name('api.analytics.feed.store'); ->name('api.analytics.feed.store');
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('discovery')->name('api.discovery.')->group(function () { Route::middleware(['web', 'auth', 'normalize.username'])->prefix('discovery')->name('api.discovery.')->group(function () {
Route::post('events', [\App\Http\Controllers\Api\DiscoveryEventController::class, 'store']) Route::post('events', [\App\Http\Controllers\Api\DiscoveryEventController::class, 'store'])
->middleware('throttle:uploads-status') ->middleware(['throttle:uploads-status', 'forum.bot.protection:api_write'])
->name('events.store'); ->name('events.store');
}); });

View File

@@ -35,7 +35,7 @@ Route::middleware(['guest', 'normalize.username'])->group(function () {
->name('register.notice'); ->name('register.notice');
Route::post('register', [RegisteredUserController::class, 'store']) Route::post('register', [RegisteredUserController::class, 'store'])
->middleware(['throttle:register-ip', 'throttle:register-ip-daily']); ->middleware(['throttle:register-ip', 'throttle:register-ip-daily', 'forum.security.firewall:register', 'forum.bot.protection:register']);
Route::post('register/resend-verification', [RegisteredUserController::class, 'resendVerification']) Route::post('register/resend-verification', [RegisteredUserController::class, 'resendVerification'])
->middleware('throttle:register') ->middleware('throttle:register')
@@ -47,7 +47,8 @@ Route::middleware(['guest', 'normalize.username'])->group(function () {
Route::get('login', [AuthenticatedSessionController::class, 'create']) Route::get('login', [AuthenticatedSessionController::class, 'create'])
->name('login'); ->name('login');
Route::post('login', [AuthenticatedSessionController::class, 'store']); Route::post('login', [AuthenticatedSessionController::class, 'store'])
->middleware(['forum.security.firewall:login', 'forum.bot.protection:login']);
Route::get('forgot-password', [PasswordResetLinkController::class, 'create']) Route::get('forgot-password', [PasswordResetLinkController::class, 'create'])
->name('password.request'); ->name('password.request');

View File

@@ -110,7 +110,26 @@ Schedule::command('nova:recalculate-rankings --sync-rank-scores')
->withoutOverlapping() ->withoutOverlapping()
->runInBackground(); ->runInBackground();
Schedule::command('forum:scan-posts') Schedule::command('forum:ai-scan')
->everyTenMinutes() ->everyTenMinutes()
->name('forum-scan-posts') ->name('forum-ai-scan')
->withoutOverlapping(); ->withoutOverlapping()
->runInBackground();
Schedule::command('forum:bot-scan')
->everyFiveMinutes()
->name('forum-bot-scan')
->withoutOverlapping()
->runInBackground();
Schedule::command('forum:scan-posts --limit=250')
->everyFifteenMinutes()
->name('forum-post-scan')
->withoutOverlapping()
->runInBackground();
Schedule::command('forum:firewall-scan')
->everyFiveMinutes()
->name('forum-firewall-scan')
->withoutOverlapping()
->runInBackground();

View File

@@ -273,18 +273,18 @@ Route::middleware(['auth', 'normalize.username', 'ensure.onboarding.complete'])-
Route::match(['post', 'put'], '/profile/password', [ProfileController::class, 'password'])->name('profile.password'); Route::match(['post', 'put'], '/profile/password', [ProfileController::class, 'password'])->name('profile.password');
Route::post('/avatar/upload', [AvatarController::class, 'upload'])->middleware('throttle:20,1')->name('avatar.upload'); Route::post('/avatar/upload', [AvatarController::class, 'upload'])->middleware('throttle:20,1')->name('avatar.upload');
Route::post('/settings/profile/update', [ProfileController::class, 'updateProfileSection'])->name('settings.profile.update'); Route::post('/settings/profile/update', [ProfileController::class, 'updateProfileSection'])->middleware('forum.bot.protection:profile_update')->name('settings.profile.update');
Route::post('/settings/account/username', [ProfileController::class, 'updateUsername'])->name('settings.account.username'); Route::post('/settings/account/username', [ProfileController::class, 'updateUsername'])->middleware('forum.bot.protection:profile_update')->name('settings.account.username');
Route::post('/settings/account/update', [ProfileController::class, 'updateAccountSection'])->name('settings.account.update'); Route::post('/settings/account/update', [ProfileController::class, 'updateAccountSection'])->middleware('forum.bot.protection:profile_update')->name('settings.account.update');
Route::post('/settings/email/request', [ProfileController::class, 'requestEmailChange']) Route::post('/settings/email/request', [ProfileController::class, 'requestEmailChange'])
->middleware('throttle:email-change-request') ->middleware('throttle:email-change-request')
->name('settings.email.request'); ->name('settings.email.request');
Route::post('/settings/email/verify', [ProfileController::class, 'verifyEmailChange']) Route::post('/settings/email/verify', [ProfileController::class, 'verifyEmailChange'])
->middleware('throttle:10,1') ->middleware('throttle:10,1')
->name('settings.email.verify'); ->name('settings.email.verify');
Route::post('/settings/personal/update', [ProfileController::class, 'updatePersonalSection'])->name('settings.personal.update'); Route::post('/settings/personal/update', [ProfileController::class, 'updatePersonalSection'])->middleware('forum.bot.protection:profile_update')->name('settings.personal.update');
Route::post('/settings/notifications/update', [ProfileController::class, 'updateNotificationsSection'])->name('settings.notifications.update'); Route::post('/settings/notifications/update', [ProfileController::class, 'updateNotificationsSection'])->middleware('forum.bot.protection:profile_update')->name('settings.notifications.update');
Route::post('/settings/security/password', [ProfileController::class, 'updateSecurityPassword'])->name('settings.security.password'); Route::post('/settings/security/password', [ProfileController::class, 'updateSecurityPassword'])->middleware('forum.bot.protection:profile_update')->name('settings.security.password');
}); });
// ── UPLOAD ──────────────────────────────────────────────────────────────────── // ── UPLOAD ────────────────────────────────────────────────────────────────────
@@ -377,6 +377,10 @@ Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function ()
->middleware('admin.moderation') ->middleware('admin.moderation')
->name('reports.queue'); ->name('reports.queue');
Route::get('reports/tags', [\App\Http\Controllers\Admin\TagInteractionReportController::class, 'index'])
->middleware('admin.moderation')
->name('reports.tags');
Route::middleware('admin.moderation')->prefix('early-growth')->name('early-growth.')->group(function () { Route::middleware('admin.moderation')->prefix('early-growth')->name('early-growth.')->group(function () {
Route::get('/', [\App\Http\Controllers\Admin\EarlyGrowthAdminController::class, 'index'])->name('index'); Route::get('/', [\App\Http\Controllers\Admin\EarlyGrowthAdminController::class, 'index'])->name('index');
Route::delete('/cache', [\App\Http\Controllers\Admin\EarlyGrowthAdminController::class, 'flushCache'])->name('cache.flush'); Route::delete('/cache', [\App\Http\Controllers\Admin\EarlyGrowthAdminController::class, 'flushCache'])->name('cache.flush');
@@ -411,6 +415,9 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('messages')->n
}); });
// ── COMMUNITY ACTIVITY ──────────────────────────────────────────────────────── // ── COMMUNITY ACTIVITY ────────────────────────────────────────────────────────
Route::match(['get', 'post'], '/community/chat', [\App\Http\Controllers\Community\ChatController::class, 'index'])
->name('community.chat');
Route::get('/community/activity', [\App\Http\Controllers\Web\CommunityActivityController::class, 'index']) Route::get('/community/activity', [\App\Http\Controllers\Web\CommunityActivityController::class, 'index'])
->name('community.activity'); ->name('community.activity');
@@ -430,9 +437,11 @@ Route::get('/feed/search', [\App\Http\Controllers\Web\Posts\SearchFeedController
->name('feed.search'); ->name('feed.search');
// ── CONTENT BROWSER (artwork / category universal router) ───────────────────── // ── CONTENT BROWSER (artwork / category universal router) ─────────────────────
// Bind the artwork route parameter to the Artwork model by slug. // Bind the artwork route parameter by slug when possible, but don't hard-fail.
// Some URLs that match this shape are actually nested category paths such as
// /skins/audio/blazemedia-pro, which should fall through to category handling.
Route::bind('artwork', function ($value) { Route::bind('artwork', function ($value) {
return Artwork::where('slug', $value)->firstOrFail(); return Artwork::where('slug', $value)->first();
}); });
Route::get('/{contentTypeSlug}/{categoryPath}/{artwork}', [BrowseGalleryController::class, 'showArtwork']) Route::get('/{contentTypeSlug}/{categoryPath}/{artwork}', [BrowseGalleryController::class, 'showArtwork'])

View File

@@ -0,0 +1,95 @@
<?php
use cPad\Plugins\Forum\Services\Security\AccountFarmDetector;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
uses(Tests\TestCase::class);
it('flags repeated posting patterns across multiple accounts', function () {
config()->set('forum_bot_protection.account_farm', [
'window_minutes' => 10,
'register_attempt_threshold' => 10,
'same_ip_users_threshold' => 5,
'same_fingerprint_users_threshold' => 3,
'same_pattern_users_threshold' => 2,
'register_attempt_penalty' => 50,
'same_ip_penalty' => 35,
'same_fingerprint_penalty' => 40,
'same_pattern_penalty' => 45,
]);
Schema::dropIfExists('forum_posts');
Schema::dropIfExists('forum_bot_device_fingerprints');
Schema::dropIfExists('forum_bot_logs');
Schema::create('forum_bot_logs', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('ip_address', 45)->nullable();
$table->string('action', 80);
$table->unsignedTinyInteger('risk_score')->default(0);
$table->string('decision', 20)->default('allow');
$table->json('metadata')->nullable();
$table->timestamp('created_at')->nullable();
});
Schema::create('forum_bot_device_fingerprints', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('fingerprint', 128)->nullable();
$table->timestamp('first_seen')->nullable();
$table->timestamp('last_seen')->nullable();
$table->unsignedTinyInteger('risk_score')->default(0);
$table->string('user_agent_hash', 64)->nullable();
$table->timestamps();
});
Schema::create('forum_posts', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('thread_id')->nullable();
$table->unsignedBigInteger('topic_id')->nullable();
$table->string('source_ip_hash', 64)->nullable();
$table->unsignedBigInteger('user_id')->nullable();
$table->longText('content')->nullable();
$table->string('content_hash', 64)->nullable();
$table->boolean('is_edited')->default(false);
$table->timestamp('edited_at')->nullable();
$table->unsignedInteger('spam_score')->default(0);
$table->unsignedInteger('quality_score')->default(0);
$table->unsignedInteger('ai_spam_score')->default(0);
$table->unsignedInteger('ai_toxicity_score')->default(0);
$table->unsignedInteger('behavior_score')->default(0);
$table->unsignedInteger('link_score')->default(0);
$table->integer('learning_score')->default(0);
$table->unsignedInteger('risk_score')->default(0);
$table->integer('trust_modifier')->default(0);
$table->boolean('flagged')->default(false);
$table->string('flagged_reason')->nullable();
$table->boolean('moderation_checked')->default(false);
$table->string('moderation_status')->nullable();
$table->json('moderation_labels')->nullable();
$table->json('moderation_meta')->nullable();
$table->timestamp('last_ai_scan_at')->nullable();
$table->timestamps();
$table->softDeletes();
});
$hash = hash('sha256', 'buy cheap backlinks now');
foreach ([1, 2, 3] as $userId) {
DB::table('forum_posts')->insert([
'user_id' => $userId,
'content' => 'buy cheap backlinks now',
'content_hash' => $hash,
'created_at' => now()->subMinutes(2),
'updated_at' => now()->subMinutes(2),
]);
}
$result = app(AccountFarmDetector::class)->analyze(1, '203.0.113.10', null, 'forum_reply_create');
expect($result['score'])->toBe(45)
->and($result['reasons'])->toContain('Posting patterns or repeated content overlap across multiple accounts.');
});

View File

@@ -0,0 +1,52 @@
<?php
use cPad\Plugins\Forum\Services\Security\BotRiskScorer;
uses(Tests\TestCase::class);
it('maps bot risk thresholds to the expected decisions', function () {
config()->set('forum_bot_protection.thresholds', [
'allow' => 20,
'log' => 20,
'captcha' => 40,
'moderate' => 60,
'block' => 80,
]);
$scorer = app(BotRiskScorer::class);
expect($scorer->score(['behavior' => 10]))->toMatchArray([
'risk_score' => 10,
'decision' => 'allow',
'requires_review' => false,
'blocked' => false,
]);
expect($scorer->score(['behavior' => 20]))->toMatchArray([
'risk_score' => 20,
'decision' => 'log',
'requires_review' => false,
'blocked' => false,
]);
expect($scorer->score(['behavior' => 40]))->toMatchArray([
'risk_score' => 40,
'decision' => 'captcha',
'requires_review' => false,
'blocked' => false,
]);
expect($scorer->score(['behavior' => 60]))->toMatchArray([
'risk_score' => 60,
'decision' => 'moderate',
'requires_review' => true,
'blocked' => false,
]);
expect($scorer->score(['behavior' => 80]))->toMatchArray([
'risk_score' => 80,
'decision' => 'block',
'requires_review' => false,
'blocked' => true,
]);
});

View File

@@ -0,0 +1,166 @@
<?php
use App\Http\Middleware\ForumRateLimitMiddleware;
use cPad\Plugins\Forum\Services\Security\BotProtectionService;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Http\Request;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Symfony\Component\HttpFoundation\Response;
uses(Tests\TestCase::class);
it('reports forum throttle violations to bot protection before rethrowing', function () {
$request = Request::create('/forum/topic/example-topic/reply', 'POST');
$request->setRouteResolver(static fn (): object => new class {
public function getName(): string
{
return 'forum.topic.reply';
}
});
$throttle = \Mockery::mock(ThrottleRequests::class);
$botProtection = \Mockery::mock(BotProtectionService::class);
$exception = new ThrottleRequestsException('Too Many Attempts.', null, [
'Retry-After' => '42',
'X-RateLimit-Limit' => '3',
'X-RateLimit-Remaining' => '0',
]);
$throttle->shouldReceive('handle')
->once()
->with($request, \Mockery::type(Closure::class), 'forum-post-write')
->andThrow($exception);
$botProtection->shouldReceive('recordRateLimitViolation')
->once()
->with(
$request,
'forum_reply_create',
\Mockery::on(static function (array $context): bool {
return $context['limiter'] === 'forum-post-write'
&& $context['bucket'] === 'minute'
&& $context['max_attempts'] === 3
&& $context['retry_after'] === 42;
})
);
$middleware = new ForumRateLimitMiddleware($throttle, $botProtection);
$next = static fn (): Response => response('ok');
$middleware->handle($request, $next);
})->throws(ThrottleRequestsException::class);
it('classifies forum hourly limiter violations using the actual limit bucket', function () {
$request = Request::create('/forum/topic/example-topic/reply', 'POST');
$request->setRouteResolver(static fn (): object => new class {
public function getName(): string
{
return 'forum.topic.reply';
}
});
$throttle = \Mockery::mock(ThrottleRequests::class);
$botProtection = \Mockery::mock(BotProtectionService::class);
$exception = new ThrottleRequestsException('Too Many Attempts.', null, [
'Retry-After' => '120',
'X-RateLimit-Limit' => '10',
'X-RateLimit-Remaining' => '0',
]);
$throttle->shouldReceive('handle')
->once()
->with($request, \Mockery::type(Closure::class), 'forum-post-write')
->andThrow($exception);
$botProtection->shouldReceive('recordRateLimitViolation')
->once()
->with(
$request,
'forum_reply_create',
\Mockery::on(static function (array $context): bool {
return $context['bucket'] === 'hour'
&& $context['max_attempts'] === 10
&& $context['retry_after'] === 120;
})
);
$middleware = new ForumRateLimitMiddleware($throttle, $botProtection);
$next = static fn (): Response => response('ok');
$middleware->handle($request, $next);
})->throws(ThrottleRequestsException::class);
it('classifies thread creation minute and hour limiter buckets correctly', function () {
$request = Request::create('/forum/example-board/new', 'POST');
$request->setRouteResolver(static fn (): object => new class {
public function getName(): string
{
return 'forum.topic.store';
}
});
$throttle = \Mockery::mock(ThrottleRequests::class);
$botProtection = \Mockery::mock(BotProtectionService::class);
$minuteException = new ThrottleRequestsException('Too Many Attempts.', null, [
'Retry-After' => '30',
'X-RateLimit-Limit' => '3',
'X-RateLimit-Remaining' => '0',
]);
$hourException = new ThrottleRequestsException('Too Many Attempts.', null, [
'Retry-After' => '120',
'X-RateLimit-Limit' => '10',
'X-RateLimit-Remaining' => '0',
]);
$throttle->shouldReceive('handle')
->once()
->with($request, \Mockery::type(Closure::class), 'forum-thread-create')
->andThrow($minuteException);
$throttle->shouldReceive('handle')
->once()
->with($request, \Mockery::type(Closure::class), 'forum-thread-create')
->andThrow($hourException);
$botProtection->shouldReceive('recordRateLimitViolation')
->once()
->with(
$request,
'forum_topic_create',
\Mockery::on(static function (array $context): bool {
return $context['limiter'] === 'forum-thread-create'
&& $context['bucket'] === 'minute'
&& $context['max_attempts'] === 3
&& $context['retry_after'] === 30;
})
);
$botProtection->shouldReceive('recordRateLimitViolation')
->once()
->with(
$request,
'forum_topic_create',
\Mockery::on(static function (array $context): bool {
return $context['limiter'] === 'forum-thread-create'
&& $context['bucket'] === 'hour'
&& $context['max_attempts'] === 10
&& $context['retry_after'] === 120;
})
);
$middleware = new ForumRateLimitMiddleware($throttle, $botProtection);
$next = static fn (): Response => response('ok');
try {
$middleware->handle($request, $next);
} catch (ThrottleRequestsException) {
}
$middleware->handle($request, $next);
})->throws(ThrottleRequestsException::class);

View File

@@ -0,0 +1,482 @@
<?php
use App\Models\User;
use cPad\Plugins\Forum\Models\ForumBoard;
use cPad\Plugins\Forum\Models\ForumCategory;
use cPad\Plugins\Forum\Models\ForumPost;
use cPad\Plugins\Forum\Models\ForumTopic;
use cPad\Plugins\Forum\Services\ForumModerationService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Schema;
uses(Tests\TestCase::class);
it('enforces forum write rate limits for thread creation and replies', function () {
ensureForumRateLimitModelClassesLoaded();
Queue::fake();
config()->set('forum_bot_protection.enabled', false);
config()->set('forum_bot_protection.behavior.new_account_days', 0);
config()->set('skinbase_ai_moderation.enabled', false);
$moderationService = \Mockery::mock(ForumModerationService::class);
$moderationService->shouldReceive('preflight')->andReturnUsing(static function ($user, $content, $sourceIp): array {
return [
'spam_score' => 0,
'quality_score' => 0,
'ai_spam_score' => 0,
'ai_toxicity_score' => 0,
'behavior_score' => 0,
'link_score' => 0,
'learning_score' => 0,
'risk_score' => 0,
'trust_modifier' => 0,
'decision' => 'allowed',
'captcha_required' => false,
'blocked' => false,
'requires_review' => false,
'flagged' => false,
'reason' => null,
'content_hash' => hash('sha256', (string) $content),
'pattern_signature' => null,
'source_ip_hash' => $sourceIp ? hash('sha256', $sourceIp) : null,
'moderation_labels' => ['preflight', 'allowed'],
'provider' => 'none',
'provider_available' => false,
'language' => null,
];
});
$moderationService->shouldReceive('applyPreflightAssessment')->andReturnUsing(static function (ForumPost $post, array $assessment): void {
$post->forceFill([
'source_ip_hash' => $assessment['source_ip_hash'] ?? $post->source_ip_hash,
'content_hash' => $assessment['content_hash'] ?? $post->content_hash,
'spam_score' => (int) ($assessment['spam_score'] ?? 0),
'quality_score' => (int) ($assessment['quality_score'] ?? 0),
'ai_spam_score' => (int) ($assessment['ai_spam_score'] ?? 0),
'ai_toxicity_score' => (int) ($assessment['ai_toxicity_score'] ?? 0),
'behavior_score' => (int) ($assessment['behavior_score'] ?? 0),
'link_score' => (int) ($assessment['link_score'] ?? 0),
'learning_score' => (int) ($assessment['learning_score'] ?? 0),
'risk_score' => (int) ($assessment['risk_score'] ?? 0),
'trust_modifier' => (int) ($assessment['trust_modifier'] ?? 0),
'flagged' => (bool) ($assessment['flagged'] ?? false),
'flagged_reason' => $assessment['reason'] ?? null,
'moderation_checked' => false,
'moderation_status' => 'pending_ai_scan',
'moderation_labels' => (array) ($assessment['moderation_labels'] ?? []),
'moderation_meta' => [
'provider' => $assessment['provider'] ?? 'none',
'provider_available' => (bool) ($assessment['provider_available'] ?? false),
'language' => $assessment['language'] ?? null,
],
])->save();
});
$moderationService->shouldReceive('logRequestSecurity')->andReturnNull();
$moderationService->shouldReceive('dispatchAsyncScan')->andReturnNull();
$this->app->instance(ForumModerationService::class, $moderationService);
createForumRateLimitTestSchema();
$user = User::query()->create([
'username' => 'ratelimit-user',
'username_changed_at' => now()->subDays(120),
'last_username_change_at' => now()->subDays(120),
'onboarding_step' => 'complete',
'name' => 'Rate Limit User',
'email' => 'ratelimit@example.com',
'email_verified_at' => now(),
'password' => 'password',
'is_active' => true,
]);
markForumRateLimitUserAsEstablished($user);
$board = makeForumBoard('thread-limit');
clearForumRateLimiters((string) $user->id);
for ($attempt = 1; $attempt <= 3; $attempt++) {
$response = $this->actingAs($user)->post(route('forum.topic.store', ['boardSlug' => $board->slug]), [
'title' => 'Rate limit topic ' . $attempt,
'content' => 'Thread body ' . $attempt,
]);
$response->assertRedirect();
}
$this->actingAs($user)->post(route('forum.topic.store', ['boardSlug' => $board->slug]), [
'title' => 'Rate limit topic 4',
'content' => 'Thread body 4',
])->assertStatus(429);
expect(ForumTopic::query()->count())->toBe(3)
->and(ForumPost::query()->count())->toBe(3);
clearForumRateLimiters((string) $user->id);
$replyUser = User::query()->create([
'username' => 'reply-limit-user',
'username_changed_at' => now()->subDays(120),
'last_username_change_at' => now()->subDays(120),
'onboarding_step' => 'complete',
'name' => 'Reply Limit User',
'email' => 'replylimit@example.com',
'email_verified_at' => now(),
'password' => 'password',
'is_active' => true,
]);
markForumRateLimitUserAsEstablished($replyUser);
clearForumRateLimiters((string) $replyUser->id);
$topic = makeForumTopic($replyUser);
for ($attempt = 1; $attempt <= 3; $attempt++) {
$response = $this->actingAs($replyUser)->post(route('forum.topic.reply', ['topic' => $topic->slug]), [
'content' => 'Reply burst ' . $attempt,
]);
$response->assertRedirect();
}
$this->actingAs($replyUser)->post(route('forum.topic.reply', ['topic' => $topic->slug]), [
'content' => 'Reply burst 4',
])->assertStatus(429);
expect(ForumPost::query()->where('topic_id', $topic->id)->count())->toBe(4);
clearForumRateLimiters((string) $user->id);
clearForumRateLimiters((string) $replyUser->id);
});
function createForumRateLimitTestSchema(): void
{
foreach ([
'forum_security_logs',
'forum_firewall_logs',
'forum_bot_ip_blacklist',
'forum_spam_signatures',
'forum_spam_learning',
'forum_spam_domains',
'forum_spam_keywords',
'forum_topic_tags',
'forum_tags',
'forum_posts',
'forum_topics',
'forum_threads',
'forum_boards',
'forum_categories',
'users',
] as $table) {
Schema::dropIfExists($table);
}
Schema::create('forum_bot_ip_blacklist', function (Blueprint $table): void {
$table->id();
$table->string('ip_address', 45)->unique();
$table->string('reason', 255)->nullable();
$table->unsignedTinyInteger('risk_score')->default(100);
$table->timestamp('expires_at')->nullable();
$table->timestamp('created_at')->nullable();
});
Schema::create('forum_firewall_logs', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('ip_address', 45)->nullable();
$table->string('action', 80);
$table->unsignedTinyInteger('risk_score')->default(0);
$table->string('decision', 20)->default('allow');
$table->string('threat_type', 80)->nullable();
$table->string('reason', 255)->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
});
Schema::create('forum_security_logs', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('user_id')->nullable();
$table->unsignedBigInteger('post_id')->nullable();
$table->string('ip_address', 45)->nullable();
$table->string('action', 80);
$table->unsignedTinyInteger('risk_score')->default(0);
$table->string('decision', 20)->default('allow');
$table->string('reason', 255)->nullable();
$table->json('layer_scores')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
});
Schema::create('forum_spam_signatures', function (Blueprint $table): void {
$table->id();
$table->string('content_hash', 64)->nullable()->index();
$table->string('pattern_signature', 191)->nullable()->index();
$table->string('source', 32)->nullable();
$table->string('reason', 255)->nullable();
$table->unsignedInteger('confidence')->default(0);
$table->unsignedBigInteger('reviewed_by')->nullable();
$table->json('metadata')->nullable();
$table->timestamp('created_at')->nullable();
});
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('username')->nullable();
$table->timestamp('username_changed_at')->nullable();
$table->timestamp('last_username_change_at')->nullable();
$table->string('onboarding_step')->nullable();
$table->string('name')->nullable();
$table->string('email')->nullable();
$table->timestamp('email_verified_at')->nullable();
$table->string('password')->nullable();
$table->boolean('is_active')->default(true);
$table->unsignedInteger('trust_score')->default(0);
$table->unsignedInteger('approved_posts')->default(0);
$table->unsignedInteger('flagged_posts')->default(0);
$table->string('role')->nullable();
$table->rememberToken();
$table->timestamps();
$table->softDeletes();
});
Schema::create('forum_categories', function (Blueprint $table): void {
$table->id();
$table->string('name')->nullable();
$table->string('title')->nullable();
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->unsignedBigInteger('parent_id')->nullable();
$table->integer('position')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('forum_boards', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('category_id');
$table->unsignedBigInteger('legacy_category_id')->nullable();
$table->string('title');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->string('icon')->nullable();
$table->string('image')->nullable();
$table->integer('position')->default(0);
$table->boolean('is_active')->default(true);
$table->boolean('is_read_only')->default(false);
$table->timestamps();
});
Schema::create('forum_threads', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('category_id');
$table->unsignedBigInteger('user_id');
$table->string('title');
$table->string('slug')->unique();
$table->longText('content');
$table->unsignedInteger('views')->default(0);
$table->boolean('is_locked')->default(false);
$table->boolean('is_pinned')->default(false);
$table->string('visibility')->default('public');
$table->timestamp('last_post_at')->nullable();
$table->timestamps();
$table->softDeletes();
});
Schema::create('forum_topics', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('board_id');
$table->unsignedBigInteger('user_id');
$table->unsignedBigInteger('artwork_id')->nullable();
$table->unsignedBigInteger('legacy_thread_id')->nullable();
$table->string('title');
$table->string('slug')->unique();
$table->unsignedInteger('views')->default(0);
$table->unsignedInteger('replies_count')->default(0);
$table->boolean('is_pinned')->default(false);
$table->boolean('is_locked')->default(false);
$table->boolean('is_deleted')->default(false);
$table->unsignedBigInteger('last_post_id')->nullable();
$table->timestamp('last_post_at')->nullable();
$table->timestamps();
});
Schema::create('forum_posts', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('thread_id');
$table->unsignedBigInteger('topic_id')->nullable();
$table->string('source_ip_hash', 64)->nullable();
$table->unsignedBigInteger('user_id');
$table->longText('content');
$table->string('content_hash', 64)->nullable();
$table->boolean('is_edited')->default(false);
$table->timestamp('edited_at')->nullable();
$table->unsignedInteger('spam_score')->default(0);
$table->unsignedInteger('quality_score')->default(0);
$table->unsignedInteger('ai_spam_score')->default(0);
$table->unsignedInteger('ai_toxicity_score')->default(0);
$table->unsignedInteger('behavior_score')->default(0);
$table->unsignedInteger('link_score')->default(0);
$table->integer('learning_score')->default(0);
$table->unsignedInteger('risk_score')->default(0);
$table->integer('trust_modifier')->default(0);
$table->boolean('flagged')->default(false);
$table->string('flagged_reason')->nullable();
$table->boolean('moderation_checked')->default(false);
$table->string('moderation_status')->nullable();
$table->json('moderation_labels')->nullable();
$table->json('moderation_meta')->nullable();
$table->timestamp('last_ai_scan_at')->nullable();
$table->timestamps();
$table->softDeletes();
});
Schema::create('forum_tags', function (Blueprint $table): void {
$table->id();
$table->string('name')->unique();
$table->string('slug')->unique();
$table->timestamps();
});
Schema::create('forum_topic_tags', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('topic_id');
$table->unsignedBigInteger('tag_id');
$table->timestamps();
$table->unique(['topic_id', 'tag_id']);
});
Schema::create('forum_spam_domains', function (Blueprint $table): void {
$table->id();
$table->string('domain')->unique();
$table->timestamps();
});
Schema::create('forum_spam_keywords', function (Blueprint $table): void {
$table->id();
$table->string('keyword', 120)->unique();
$table->timestamp('created_at')->nullable();
});
Schema::create('forum_spam_learning', function (Blueprint $table): void {
$table->id();
$table->string('content_hash', 64)->index();
$table->string('decision', 32);
$table->string('pattern_signature', 191)->nullable();
$table->unsignedBigInteger('reviewed_by')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
});
}
function ensureForumRateLimitModelClassesLoaded(): void
{
foreach ([
'packages/klevze/Plugins/Forum/Models/ForumPost.php',
'packages/klevze/Plugins/Forum/Models/ForumSpamLearning.php',
'packages/klevze/Plugins/Forum/Models/ForumAiLog.php',
'packages/klevze/Plugins/Forum/Models/ForumModerationQueue.php',
] as $relativePath) {
require_once base_path($relativePath);
}
}
function makeForumBoard(string $suffix): ForumBoard
{
$category = ForumCategory::query()->create([
'name' => 'Rate Limit Category ' . $suffix,
'title' => 'Rate Limit Category ' . $suffix,
'slug' => 'rate-limit-category-' . $suffix,
'description' => 'Test category',
'position' => 1,
'is_active' => true,
'parent_id' => null,
]);
return ForumBoard::query()->create([
'category_id' => $category->id,
'title' => 'Rate Limit Board ' . $suffix,
'slug' => 'rate-limit-board-' . $suffix,
'description' => 'Test board',
'position' => 1,
'is_active' => true,
'is_read_only' => false,
]);
}
function makeForumTopic(User $user): ForumTopic
{
$board = makeForumBoard('reply-limit');
$legacyThreadId = DB::table('forum_threads')->insertGetId([
'category_id' => $board->category_id,
'user_id' => $user->id,
'title' => 'Existing topic',
'slug' => 'existing-topic-reply-limit',
'content' => 'Opening post',
'views' => 0,
'is_locked' => false,
'is_pinned' => false,
'visibility' => 'public',
'last_post_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$topic = ForumTopic::query()->create([
'board_id' => $board->id,
'user_id' => $user->id,
'legacy_thread_id' => $legacyThreadId,
'title' => 'Existing topic',
'slug' => 'existing-topic-reply-limit',
'views' => 0,
'replies_count' => 0,
'is_pinned' => false,
'is_locked' => false,
'is_deleted' => false,
'last_post_at' => now(),
]);
$post = ForumPost::query()->create([
'thread_id' => $legacyThreadId,
'topic_id' => $topic->id,
'user_id' => $user->id,
'content' => 'Opening post',
'is_edited' => false,
]);
$topic->forceFill([
'last_post_id' => $post->id,
'last_post_at' => $post->created_at,
])->save();
return $topic;
}
function clearForumRateLimiters(string $key): void
{
foreach ([
'forum-thread-minute:' . $key,
'forum-thread-hour:' . $key,
'forum-post-minute:' . $key,
'forum-post-hour:' . $key,
] as $limiterKey) {
RateLimiter::clear($limiterKey);
}
}
function markForumRateLimitUserAsEstablished(User $user): void
{
$timestamp = now()->subDays(30);
$user->forceFill([
'created_at' => $timestamp,
'updated_at' => $timestamp,
])->save();
}

View File

@@ -0,0 +1,83 @@
<?php
use App\Models\User;
use cPad\Plugins\Forum\Models\ForumBotLog;
use cPad\Plugins\Forum\Services\Security\GeoBehaviorAnalyzer;
use Illuminate\Http\Request;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
uses(Tests\TestCase::class);
it('scores only rapid login country changes for the same account', function () {
config()->set('forum_bot_protection.geo_behavior', [
'enabled' => true,
'login_actions' => ['login'],
'country_headers' => ['CF-IPCountry'],
'recent_login_window_minutes' => 60,
'country_change_penalty' => 50,
]);
Schema::dropIfExists('forum_bot_logs');
Schema::dropIfExists('users');
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('email')->nullable();
$table->string('password')->nullable();
$table->timestamps();
$table->softDeletes();
});
Schema::create('forum_bot_logs', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('ip_address', 45)->nullable();
$table->string('action', 80);
$table->unsignedTinyInteger('risk_score')->default(0);
$table->string('decision', 20)->default('allow');
$table->json('metadata')->nullable();
$table->timestamp('created_at')->nullable();
});
DB::table('users')->insert([
'id' => 1,
'email' => 'geo@example.com',
'password' => 'secret',
'created_at' => now(),
'updated_at' => now(),
]);
$user = User::query()->findOrFail(1);
ForumBotLog::query()->create([
'user_id' => $user->id,
'ip_address' => '127.0.0.1',
'action' => 'login',
'risk_score' => 0,
'decision' => 'allow',
'metadata' => ['country_code' => 'SI'],
'created_at' => now()->subMinutes(10),
]);
$request = Request::create('/login', 'POST');
$request->headers->set('CF-IPCountry', 'SI');
$unchanged = app(GeoBehaviorAnalyzer::class)->analyze($user, 'login', $request);
expect($unchanged)->toMatchArray([
'score' => 0,
'country_code' => 'SI',
])->and($unchanged['reasons'])->toBe([]);
$request->headers->set('CF-IPCountry', 'JP');
$analysis = app(GeoBehaviorAnalyzer::class)->analyze($user, 'login', $request);
expect($analysis['score'])->toBe(50)
->and($analysis['country_code'])->toBe('JP')
->and($analysis['reasons'])->toHaveCount(1)
->and($analysis['reasons'][0])->toContain('SI')
->and($analysis['reasons'][0])->toContain('JP');
});

View File

@@ -0,0 +1,70 @@
<?php
use cPad\Plugins\Forum\Services\Security\IPReputationService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
uses(Tests\TestCase::class);
it('scores CIDR datacenter and proxy ranges in IP reputation analysis', function () {
Cache::flush();
config()->set('forum_bot_protection.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' => ['198.51.100.0/24'],
'datacenter_ranges' => ['203.0.113.0/24'],
'provider_ranges' => [
'aws' => ['54.240.0.0/12'],
],
'tor_exit_nodes' => [],
]);
Schema::dropIfExists('forum_bot_ip_blacklist');
Schema::dropIfExists('forum_bot_logs');
Schema::create('forum_bot_ip_blacklist', function (Blueprint $table): void {
$table->id();
$table->string('ip_address', 45)->unique();
$table->string('reason', 255)->nullable();
$table->unsignedTinyInteger('risk_score')->default(100);
$table->timestamp('expires_at')->nullable();
$table->timestamp('created_at')->nullable();
});
Schema::create('forum_bot_logs', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('ip_address', 45)->nullable();
$table->string('action', 80);
$table->unsignedTinyInteger('risk_score')->default(0);
$table->string('decision', 20)->default('allow');
$table->json('metadata')->nullable();
$table->timestamp('created_at')->nullable();
});
$service = app(IPReputationService::class);
$proxyResult = $service->analyze('198.51.100.23');
$datacenterResult = $service->analyze('203.0.113.77');
$providerResult = $service->analyze('54.240.10.20');
expect($proxyResult['score'])->toBe(20)
->and($proxyResult['reasons'])->toContain('IP address is in the proxy watch list.')
->and($proxyResult['blocked'])->toBeFalse();
expect($datacenterResult['score'])->toBe(25)
->and($datacenterResult['reasons'])->toContain('IP address belongs to a datacenter or hosting network range.')
->and($datacenterResult['blocked'])->toBeFalse();
expect($providerResult['score'])->toBe(25)
->and($providerResult['reasons'])->toContain('IP address belongs to the configured AWS provider range.')
->and($providerResult['blocked'])->toBeFalse();
});