diff --git a/app/Contracts/Moderation/CommentSpamClassifier.php b/app/Contracts/Moderation/CommentSpamClassifier.php new file mode 100644 index 00000000..08b25b38 --- /dev/null +++ b/app/Contracts/Moderation/CommentSpamClassifier.php @@ -0,0 +1,10 @@ +published()->findOrFail($artworkId); - $page = max(1, (int) $request->query('page', 1)); - $perPage = 20; + $page = max(1, (int) $request->query('page', 1)); + $perPage = 20; // Only fetch top-level comments (no parent). Replies are recursively eager-loaded. $comments = ArtworkComment::with([ - 'user', 'user.profile', - 'approvedReplies', - ]) + 'user', 'user.profile', + 'approvedReplies', + ]) ->where('artwork_id', $artwork->id) ->where('is_approved', true) ->whereNull('parent_id') @@ -58,15 +63,15 @@ class ArtworkCommentController extends Controller $userId = $request->user()?->id; $commentIds = $this->commentIds($comments->getCollection()); $reactionTotals = $this->commentReactions->forComments($commentIds, $userId); - $items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true, $reactionTotals)); + $items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true, $reactionTotals)); return response()->json([ 'data' => $items, 'meta' => [ 'current_page' => $comments->currentPage(), - 'last_page' => $comments->lastPage(), - 'total' => $comments->total(), - 'per_page' => $comments->perPage(), + 'last_page' => $comments->lastPage(), + 'total' => $comments->total(), + 'per_page' => $comments->perPage(), ], ]); } @@ -80,7 +85,7 @@ class ArtworkCommentController extends Controller $artwork = Artwork::public()->published()->findOrFail($artworkId); $request->validate([ - 'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH], + 'content' => ['required', 'string', 'min:1', 'max:'.self::MAX_LENGTH], 'parent_id' => ['nullable', 'integer', 'exists:artwork_comments,id'], ]); @@ -109,43 +114,54 @@ class ArtworkCommentController extends Controller $rendered = ContentSanitizer::render($raw); $comment = ArtworkComment::create([ - 'artwork_id' => $artwork->id, - 'user_id' => $request->user()->id, - 'parent_id' => $parentId, - 'content' => $raw, // legacy column (plain text fallback) - 'raw_content' => $raw, + 'artwork_id' => $artwork->id, + 'user_id' => $request->user()->id, + 'parent_id' => $parentId, + 'content' => $raw, // legacy column (plain text fallback) + 'raw_content' => $raw, 'rendered_content' => $rendered, - 'is_approved' => true, // auto-approve; extend with moderation as needed + 'is_approved' => true, ]); + $moderation = $this->commentSpam->moderate($comment, $request->user()); + // Bust the comments cache for this user's 'all' feed Cache::forget('comments.latest.all.page1'); $comment->load(['user', 'user.profile']); - $this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null); + if ($comment->is_approved) { + $this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null); + } // Record activity event (fire-and-forget; never break the response) try { - \App\Models\ActivityEvent::record( - actorId: $request->user()->id, - type: \App\Models\ActivityEvent::TYPE_COMMENT, - targetType: \App\Models\ActivityEvent::TARGET_ARTWORK, - targetId: $artwork->id, + ActivityEvent::record( + actorId: $request->user()->id, + type: ActivityEvent::TYPE_COMMENT, + targetType: ActivityEvent::TARGET_ARTWORK, + targetId: $artwork->id, ); - } catch (\Throwable) {} + } catch (\Throwable) { + } try { - app(UserActivityService::class)->logComment( - (int) $request->user()->id, - (int) $comment->id, - $parentId !== null, - ['artwork_id' => (int) $artwork->id], - ); - } catch (\Throwable) {} + if ($comment->is_approved) { + app(UserActivityService::class)->logComment( + (int) $request->user()->id, + (int) $comment->id, + $parentId !== null, + ['artwork_id' => (int) $artwork->id], + ); + } + } catch (\Throwable) { + } $reactionTotals = $this->commentReactions->forComments([$comment->id], $request->user()->id); - return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false, $reactionTotals)], 201); + return response()->json([ + 'data' => $this->formatComment($comment, $request->user()->id, false, $reactionTotals), + 'moderation' => ['status' => $moderation['status']], + ], 201); } // ───────────────────────────────────────────────────────────────────────── @@ -160,10 +176,10 @@ class ArtworkCommentController extends Controller Gate::authorize('update', $comment); $request->validate([ - 'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH], + 'content' => ['required', 'string', 'min:1', 'max:'.self::MAX_LENGTH], ]); - $raw = $request->input('content'); + $raw = $request->input('content'); $errors = ContentSanitizer::validate($raw); if ($errors) { return response()->json(['errors' => ['content' => $errors]], 422); @@ -172,8 +188,8 @@ class ArtworkCommentController extends Controller $rendered = ContentSanitizer::render($raw); $comment->update([ - 'content' => $raw, - 'raw_content' => $raw, + 'content' => $raw, + 'raw_content' => $raw, 'rendered_content' => $rendered, ]); @@ -206,27 +222,27 @@ class ArtworkCommentController extends Controller private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false, array $reactionTotals = []): array { - $user = $c->user; - $userId = (int) ($c->user_id ?? 0); + $user = $c->user; + $userId = (int) ($c->user_id ?? 0); $avatarHash = $user?->profile?->avatar_hash ?? null; $data = [ - 'id' => $c->id, - 'parent_id' => $c->parent_id, - 'raw_content' => $c->raw_content ?? $c->content, + 'id' => $c->id, + 'parent_id' => $c->parent_id, + 'raw_content' => $c->raw_content ?? $c->content, 'rendered_content' => $this->renderCommentContent($c), - 'created_at' => $c->created_at?->toIso8601String(), - 'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null, - 'can_edit' => $currentUserId === $userId, - 'can_delete' => $currentUserId === $userId, + 'created_at' => $c->created_at?->toIso8601String(), + 'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null, + 'can_edit' => $currentUserId === $userId, + 'can_delete' => $currentUserId === $userId, 'user' => [ - 'id' => $userId, - 'username' => $user?->username, - 'display' => $user?->username ?? $user?->name ?? 'User', - 'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId, - 'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64), - 'level' => (int) ($user?->level ?? 1), - 'rank' => (string) ($user?->rank ?? 'Newbie'), + 'id' => $userId, + 'username' => $user?->username, + 'display' => $user?->username ?? $user?->name ?? 'User', + 'profile_url' => $user?->username ? '/@'.$user->username : '/profile/'.$userId, + 'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64), + 'level' => (int) ($user?->level ?? 1), + 'rank' => (string) ($user?->rank ?? 'Newbie'), ], 'reactions' => $reactionTotals[(int) $c->id] ?? [], ]; diff --git a/app/Http/Middleware/CommentCaptchaMiddleware.php b/app/Http/Middleware/CommentCaptchaMiddleware.php new file mode 100644 index 00000000..4e273793 --- /dev/null +++ b/app/Http/Middleware/CommentCaptchaMiddleware.php @@ -0,0 +1,65 @@ +botProtection->assess($request, 'comment_create'); + if ((bool) ($assessment['blocked'] ?? false)) { + return response()->json(['message' => 'Suspicious activity detected.', 'errors' => ['bot' => ['Suspicious activity detected.']]], 429); + } + $threshold = (int) config('comment_spam.captcha.threshold', 40); + if ((int) ($assessment['risk_score'] ?? 0) < $threshold) { + return $next($request); + } + + $token = (string) ($request->input('comment-turnstile-response') ?: $request->header('X-Turnstile-Token', '')); + $valid = false; + if ($token !== '') { + try { + $valid = (bool) Http::asForm()->timeout(5)->post( + (string) config('comment_spam.captcha.verify_url'), + ['secret' => (string) config('comment_spam.captcha.secret_key'), 'response' => $token, 'remoteip' => $request->ip()], + )->json('success', false); + } catch (\Throwable) { + $valid = (bool) config('comment_spam.captcha.fail_open', false); + } + } + + if ($valid) { + return $next($request); + } + + $payload = [ + 'message' => 'Complete the captcha challenge to continue.', + 'errors' => ['captcha' => ['Complete the captcha challenge to continue.']], + 'requires_captcha' => true, + 'captcha' => [ + 'provider' => 'turnstile', + 'siteKey' => (string) config('comment_spam.captcha.site_key'), + 'inputName' => 'comment-turnstile-response', + 'scriptUrl' => (string) config('comment_spam.captcha.script_url'), + ], + 'captcha_provider' => 'turnstile', + 'captcha_site_key' => (string) config('comment_spam.captcha.site_key'), + 'captcha_input' => 'comment-turnstile-response', + 'captcha_script_url' => (string) config('comment_spam.captcha.script_url'), + ]; + + return response()->json($payload, 422); + } +} diff --git a/app/Models/ArtworkComment.php b/app/Models/ArtworkComment.php index 0a96ecb0..800aba7f 100644 --- a/app/Models/ArtworkComment.php +++ b/app/Models/ArtworkComment.php @@ -1,95 +1,110 @@ 'boolean', - ]; + protected $casts = [ + 'is_approved' => 'boolean', + 'spam_score' => 'integer', + 'spam_probability' => 'integer', + 'moderated_at' => 'datetime', + ]; - public function artwork(): BelongsTo - { - return $this->belongsTo(Artwork::class); - } + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class); + } - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } - public function parent(): BelongsTo - { - return $this->belongsTo(self::class, 'parent_id'); - } + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } - public function replies(): HasMany - { - return $this->hasMany(self::class, 'parent_id')->orderBy('created_at'); - } + public function replies(): HasMany + { + return $this->hasMany(self::class, 'parent_id')->orderBy('created_at'); + } - /** - * Recursively eager-load approved replies (tree structure). - */ - public function approvedReplies(): HasMany - { - return $this->hasMany(self::class, 'parent_id') - ->where('is_approved', true) - ->orderBy('created_at') - ->with(['user.profile', 'approvedReplies']); - } + /** + * Recursively eager-load approved replies (tree structure). + */ + public function approvedReplies(): HasMany + { + return $this->hasMany(self::class, 'parent_id') + ->where('is_approved', true) + ->orderBy('created_at') + ->with(['user.profile', 'approvedReplies']); + } - public function reactions(): HasMany - { - return $this->hasMany(CommentReaction::class, 'comment_id'); - } + public function reactions(): HasMany + { + return $this->hasMany(CommentReaction::class, 'comment_id'); + } - /** - * Return the best available rendered content for display. - * Falls back to escaping raw legacy content if rendering isn't done yet. - */ - public function getDisplayHtml(): string - { - if ($this->rendered_content !== null) { - return $this->rendered_content; - } + /** + * Return the best available rendered content for display. + * Falls back to escaping raw legacy content if rendering isn't done yet. + */ + public function getDisplayHtml(): string + { + if ($this->rendered_content !== null) { + return $this->rendered_content; + } - // Lazy render: raw_content takes priority over legacy content - $raw = $this->raw_content ?? $this->content ?? ''; - return \App\Services\ContentSanitizer::render($raw); - } + // Lazy render: raw_content takes priority over legacy content + $raw = $this->raw_content ?? $this->content ?? ''; + + return ContentSanitizer::render($raw); + } } diff --git a/app/Models/CommentSpamSignature.php b/app/Models/CommentSpamSignature.php new file mode 100644 index 00000000..5915adc7 --- /dev/null +++ b/app/Models/CommentSpamSignature.php @@ -0,0 +1,22 @@ + 'integer', + 'hit_count' => 'integer', + 'metadata' => 'array', + 'created_at' => 'datetime', + ]; +} diff --git a/app/Services/Moderation/CommentSpamService.php b/app/Services/Moderation/CommentSpamService.php new file mode 100644 index 00000000..1ed5c92d --- /dev/null +++ b/app/Services/Moderation/CommentSpamService.php @@ -0,0 +1,179 @@ +raw_content ?? $comment->content ?? ''); + $hash = hash('sha256', $this->normalize($content)); + + if (! (bool) config('comment_spam.enabled', true) || $mode === 'off') { + return $this->saveMetadata($comment, 0, 0, 'disabled', 'Comment spam protection disabled.'); + } + + $signature = $this->signature($hash); + if ($signature !== null) { + $this->recordSignatureHit($signature); + $isSpam = ($signature->source === 'spam' || $signature->source === 'admin_spam'); + + return $this->finish($comment, $mode, $isSpam ? 100 : 0, $isSpam ? 100 : 0, 'signature', (string) $signature->reason, $isSpam); + } + + $scan = $this->processing->process($content, [ + 'content_type' => 'artwork_comment', + 'content_id' => (int) $comment->id, + 'artwork_id' => (int) $comment->artwork_id, + 'user_id' => (int) $user->id, + 'content_snapshot' => $content, + 'comment_spam_mode' => $mode, + ], true); + + $local = (int) $scan['result']->score; + $probability = $local; + $source = 'local'; + $reason = implode(' ', array_slice($scan['result']->reasons, 0, 2)); + $isSpam = $local >= (int) config('comment_spam.local_spam_min', 70); + $needsAi = $local > (int) config('comment_spam.local_safe_max', 29) && ! $isSpam; + $aiPending = false; + + // Repeated external links and explicit copy/paste promotions are + // deterministic spam signals. Do not let an AI false negative make + // an obvious advertisement public. + if ($this->isObviousPromotionalSpam($content, (array) $scan['result']->matchedLinks)) { + $local = max($local, (int) config('comment_spam.local_spam_min', 70)); + $probability = 100; + $isSpam = true; + $needsAi = false; + $source = 'local_hard_rule'; + $reason = trim($reason.' Repeated promotional external link pattern.'); + } + + if ($needsAi && (bool) config('comment_spam.ai_enabled', true)) { + try { + $assessment = $this->ai->classify($content); + $probability = $assessment->spam + ? max($local, (int) round($assessment->confidence * 100)) + : $local; + $source = 'local+ai'; + $reason = trim(implode(' ', array_filter([$reason, $assessment->reason]))); + $isSpam = $assessment->spam && $assessment->confidence >= (float) config('comment_spam.ai.spam_confidence', 0.90); + $aiPending = $assessment->spam && ! $isSpam; + Log::info('comment_spam_ai_classification', ['comment_id' => $comment->id, 'provider' => $assessment->provider, 'model' => $assessment->model, 'spam' => $assessment->spam, 'confidence' => $assessment->confidence, 'latency_ms' => $assessment->latencyMs, 'decision' => $isSpam ? 'spam' : ($assessment->spam ? 'pending' : 'approved')]); + } catch (Throwable) { + $source = 'local+ai_error'; + $aiPending = true; + } + } + + $status = $mode === 'observe' + ? 'observed' + : ($isSpam ? 'spam' : ($needsAi && $aiPending ? 'pending' : 'approved')); + + $comment->forceFill([ + 'is_approved' => $mode === 'enforce' ? ! in_array($status, ['spam', 'pending'], true) : true, + 'spam_score' => min(100, $local), + 'spam_probability' => min(100, $probability), + 'spam_reason' => $reason !== '' ? mb_substr($reason, 0, 500) : null, + 'moderation_source' => $source, + 'moderated_at' => now(), + ])->save(); + + if ($mode === 'enforce' && $status === 'spam') { + $this->rememberSignature($hash, 'spam', $reason, $probability); + } + + return compact('status', 'local', 'probability', 'source', 'reason') + ['score' => $local]; + } + + private function finish(ArtworkComment $comment, string $mode, int $score, int $probability, string $source, string $reason, ?bool $isSpam = null): array + { + $isSpam ??= $score >= (int) config('comment_spam.local_spam_min', 70); + $status = $mode === 'observe' ? 'observed' : ($isSpam ? 'spam' : 'approved'); + $comment->forceFill([ + 'is_approved' => $mode !== 'enforce' || ! $isSpam, + 'spam_score' => $score, + 'spam_probability' => $probability, + 'spam_reason' => mb_substr($reason, 0, 500), + 'moderation_source' => $source, + 'moderated_at' => now(), + ])->save(); + + return compact('status', 'score', 'probability', 'source', 'reason'); + } + + private function saveMetadata(ArtworkComment $comment, int $score, int $probability, string $source, string $reason): array + { + $comment->forceFill(['spam_score' => $score, 'spam_probability' => $probability, 'spam_reason' => $reason, 'moderation_source' => $source, 'moderated_at' => now()])->save(); + + return ['status' => 'approved', 'score' => $score, 'probability' => $probability, 'source' => $source, 'reason' => $reason]; + } + + private function signature(string $hash): ?CommentSpamSignature + { + return Cache::remember('comment-spam:'.$hash, now()->addMinutes((int) config('comment_spam.signature_cache_minutes', 1440)), fn () => CommentSpamSignature::query()->where('content_hash', $hash)->first()); + } + + private function recordSignatureHit(CommentSpamSignature $signature): void + { + try { + if (! Schema::hasColumn('comment_spam_signatures', 'hit_count')) { + return; + } + + CommentSpamSignature::query()->whereKey($signature->getKey())->increment('hit_count'); + } catch (Throwable $e) { + Log::warning('comment_spam_signature_hit_failed', [ + 'signature_id' => $signature->getKey(), + 'message' => $e->getMessage(), + ]); + } + } + + private function rememberSignature(string $hash, string $source, string $reason, int $confidence): void + { + CommentSpamSignature::query()->updateOrCreate(['content_hash' => $hash], ['source' => $source, 'reason' => mb_substr($reason, 0, 500), 'confidence' => min(100, $confidence), 'created_at' => now()]); + Cache::forget('comment-spam:'.$hash); + } + + private function normalize(string $content): string + { + return mb_strtolower((string) preg_replace('/\s+/u', ' ', trim($content))); + } + + private function isObviousPromotionalSpam(string $content, array $matchedLinks): bool + { + $links = array_values(array_unique(array_map( + fn (string $link): string => mb_strtolower(trim($link)), + array_filter($matchedLinks, 'is_string'), + ))); + preg_match_all('#https?://[^\s<>\[\]"\'`\)]+#iu', $content, $matches); + $allLinks = array_map('mb_strtolower', $matches[0] ?? []); + $hasRepeatedLink = count($allLinks) >= 2 && count(array_unique($allLinks)) < count($allLinks); + $hasPromotion = preg_match('/\b(copy\s*(?:&|and)\s*paste|buy\s+now|cheap\s+seo|guaranteed\s+traffic|visit\s+my\s+profile)\b/iu', $content) === 1; + + return $hasRepeatedLink || ($hasPromotion && $links !== []); + } +} diff --git a/app/Services/Moderation/TogetherCommentSpamClassifier.php b/app/Services/Moderation/TogetherCommentSpamClassifier.php new file mode 100644 index 00000000..26f087ca --- /dev/null +++ b/app/Services/Moderation/TogetherCommentSpamClassifier.php @@ -0,0 +1,60 @@ +withToken($key) + ->post(rtrim((string) ($config['base_url'] ?? 'https://api.together.xyz/v1'), '/').'/chat/completions', [ + 'model' => $model, + 'temperature' => 0, + 'response_format' => ['type' => 'json_object'], + 'messages' => [ + ['role' => 'system', 'content' => 'Classify the comment as spam or not spam. Return only JSON: {"spam":true|false,"confidence":0.0,"reason":"short explanation"}. Confidence must be a number from 0 to 1. Spam includes advertising, SEO promotion, unsolicited links, scams, and bot-like promotional repetition.'], + ['role' => 'user', 'content' => mb_substr($content, 0, (int) ($config['max_input_chars'] ?? 2500))], + ], + ]); + + if ($response->failed()) { + throw new RuntimeException('Together AI request failed with status '.$response->status().'.'); + } + + $message = Arr::get($response->json(), 'choices.0.message.content'); + $decoded = is_string($message) ? json_decode($message, true) : null; + if (! is_array($decoded) || ! is_bool($decoded['spam'] ?? null) + || ! is_numeric($decoded['confidence'] ?? null) + || $decoded['confidence'] < 0 || $decoded['confidence'] > 1 + || ! is_string($decoded['reason'] ?? null)) { + throw new RuntimeException('Together AI returned invalid moderation JSON.'); + } + + return new CommentSpamClassification( + $decoded['spam'], + (float) $decoded['confidence'], + mb_substr(trim($decoded['reason']), 0, 500), + 'together', + $model, + (int) round((microtime(true) - $started) * 1000), + ); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 37eebb4d..58451921 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -60,6 +60,7 @@ return Application::configure(basePath: dirname(__DIR__)) 'ensure.onboarding.complete'=> \App\Http\Middleware\EnsureOnboardingComplete::class, 'forum.ai.moderation' => \App\Http\Middleware\ForumAIModerationMiddleware::class, 'forum.bot.protection' => \App\Http\Middleware\ForumBotProtectionMiddleware::class, + 'comment.captcha' => \App\Http\Middleware\CommentCaptchaMiddleware::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, diff --git a/config/comment_spam.php b/config/comment_spam.php new file mode 100644 index 00000000..3ad1382f --- /dev/null +++ b/config/comment_spam.php @@ -0,0 +1,34 @@ + (bool) env('COMMENT_SPAM_ENABLED', true), + 'mode' => env('COMMENT_SPAM_MODE', 'enforce'), // observe|enforce|off + 'ai_enabled' => (bool) env('COMMENT_SPAM_AI_ENABLED', true), + 'ai' => [ + 'provider' => env('COMMENT_SPAM_AI_PROVIDER', 'together'), + 'api_key' => env('TOGETHER_API_KEY'), + 'base_url' => env('TOGETHER_API_BASE_URL', 'https://api.together.xyz/v1'), + 'model' => env('COMMENT_SPAM_AI_MODEL', env('TOGETHER_MODEL', 'meta-llama/Llama-3.2-3B-Instruct-Turbo')), + 'allowed_models' => [ + 'Prism-ML/Ternary-Bonsai-27B', + 'meta-llama/Llama-3.2-3B-Instruct-Turbo', + ], + 'timeout' => min(5, max(1, (int) env('COMMENT_SPAM_AI_TIMEOUT', 5))), + 'max_input_chars' => min(2500, max(100, (int) env('COMMENT_SPAM_AI_MAX_INPUT_CHARS', 2500))), + 'spam_confidence' => (float) env('COMMENT_SPAM_AI_SPAM_CONFIDENCE', 0.90), + ], + 'local_safe_max' => (int) env('COMMENT_SPAM_LOCAL_SAFE_MAX', 29), + 'local_spam_min' => (int) env('COMMENT_SPAM_LOCAL_SPAM_MIN', 70), + 'ai_spam_min' => (int) env('COMMENT_SPAM_AI_SPAM_MIN', 70), + 'signature_cache_minutes' => (int) env('COMMENT_SPAM_SIGNATURE_CACHE_MINUTES', 1440), + 'scanner_version' => env('COMMENT_SPAM_SCANNER_VERSION', 'comment-v1'), + 'captcha' => [ + 'enabled' => (bool) env('COMMENT_TURNSTILE_ENABLED', false), + 'site_key' => env('COMMENT_TURNSTILE_SITE_KEY', ''), + 'secret_key' => env('COMMENT_TURNSTILE_SECRET_KEY', ''), + 'threshold' => (int) env('COMMENT_TURNSTILE_RISK_THRESHOLD', 40), + 'fail_open' => (bool) env('COMMENT_TURNSTILE_FAIL_OPEN', false), + 'script_url' => env('COMMENT_TURNSTILE_SCRIPT_URL', 'https://challenges.cloudflare.com/turnstile/v0/api.js'), + 'verify_url' => env('COMMENT_TURNSTILE_VERIFY_URL', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'), + ], +]; diff --git a/database/migrations/2026_09_10_000010_add_comment_spam_metadata.php b/database/migrations/2026_09_10_000010_add_comment_spam_metadata.php new file mode 100644 index 00000000..b88a25c0 --- /dev/null +++ b/database/migrations/2026_09_10_000010_add_comment_spam_metadata.php @@ -0,0 +1,45 @@ +unsignedTinyInteger('spam_score')->nullable()->after('is_approved'); + $table->unsignedTinyInteger('spam_probability')->nullable()->after('spam_score'); + $table->string('spam_reason', 500)->nullable()->after('spam_probability'); + $table->string('moderation_source', 40)->nullable()->after('spam_reason'); + $table->timestamp('moderated_at')->nullable()->after('moderation_source'); + $table->index(['spam_score', 'moderated_at'], 'artwork_comments_spam_score_idx'); + }); + + Schema::create('comment_spam_signatures', function (Blueprint $table): void { + $table->id(); + $table->string('content_hash', 64)->unique(); + $table->string('pattern_signature', 64)->nullable()->index(); + $table->string('source', 40); + $table->string('reason', 500)->nullable(); + $table->unsignedTinyInteger('confidence')->default(0); + $table->unsignedInteger('hit_count')->default(0); + $table->unsignedBigInteger('reviewed_by')->nullable()->index(); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->useCurrent(); + }); + } + + public function down(): void + { + Schema::dropIfExists('comment_spam_signatures'); + Schema::table('artwork_comments', function (Blueprint $table): void { + $table->dropIndex('artwork_comments_spam_score_idx'); + $table->dropColumn([ + 'spam_score', 'spam_probability', 'spam_reason', + 'moderation_source', 'moderated_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_09_17_130000_add_hit_count_to_comment_spam_signatures_table.php b/database/migrations/2026_09_17_130000_add_hit_count_to_comment_spam_signatures_table.php new file mode 100644 index 00000000..ff7f2801 --- /dev/null +++ b/database/migrations/2026_09_17_130000_add_hit_count_to_comment_spam_signatures_table.php @@ -0,0 +1,75 @@ +id(); + $table->string('content_hash', 64)->unique(); + $table->string('pattern_signature', 64)->nullable()->index(); + $table->string('source', 40); + $table->string('reason', 500)->nullable(); + $table->unsignedTinyInteger('confidence')->default(0); + $table->unsignedInteger('hit_count')->default(0); + $table->unsignedBigInteger('reviewed_by')->nullable()->index(); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->useCurrent(); + }); + + return; + } + + Schema::table('comment_spam_signatures', function (Blueprint $table): void { + if (! Schema::hasColumn('comment_spam_signatures', 'pattern_signature')) { + $table->string('pattern_signature', 64)->nullable()->index(); + } + + if (! Schema::hasColumn('comment_spam_signatures', 'source')) { + $table->string('source', 40)->nullable(); + } + + if (! Schema::hasColumn('comment_spam_signatures', 'reason')) { + $table->string('reason', 500)->nullable(); + } + + if (! Schema::hasColumn('comment_spam_signatures', 'confidence')) { + $table->unsignedTinyInteger('confidence')->default(0); + } + + if (! Schema::hasColumn('comment_spam_signatures', 'hit_count')) { + $table->unsignedInteger('hit_count')->default(0); + } + + if (! Schema::hasColumn('comment_spam_signatures', 'reviewed_by')) { + $table->unsignedBigInteger('reviewed_by')->nullable()->index(); + } + + if (! Schema::hasColumn('comment_spam_signatures', 'metadata')) { + $table->json('metadata')->nullable(); + } + + if (! Schema::hasColumn('comment_spam_signatures', 'created_at')) { + $table->timestamp('created_at')->nullable()->useCurrent(); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('comment_spam_signatures') || ! Schema::hasColumn('comment_spam_signatures', 'hit_count')) { + return; + } + + Schema::table('comment_spam_signatures', function (Blueprint $table): void { + $table->dropColumn('hit_count'); + }); + } +}; diff --git a/resources/js/components/comments/CommentForm.jsx b/resources/js/components/comments/CommentForm.jsx index 473432fd..8ab5203f 100644 --- a/resources/js/components/comments/CommentForm.jsx +++ b/resources/js/components/comments/CommentForm.jsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import axios from 'axios' import ReactMarkdown from 'react-markdown' import EmojiPickerButton from './EmojiPickerButton' +import TurnstileField from '../security/TurnstileField' /* ── Toolbar icon components ──────────────────────────────────────────────── */ function BoldIcon() { @@ -96,6 +97,8 @@ export default function CommentForm({ const [tab, setTab] = useState('write') // 'write' | 'preview' const [submitting, setSubmitting] = useState(false) const [errors, setErrors] = useState([]) + const [captcha, setCaptcha] = useState({ required: false, token: '', provider: 'turnstile', siteKey: '', scriptUrl: '', inputName: 'cf-turnstile-response', nonce: 0 }) + const captchaAutoSubmitRef = useRef(false) const textareaRef = useRef(null) const formRef = useRef(null) const resolvedSubmitUrl = submitUrl || (artworkId ? `/api/artworks/${artworkId}/comments` : null) @@ -234,6 +237,17 @@ export default function CommentForm({ } }, [wrapSelection, insertLink]) + const handleCaptchaToken = useCallback((token) => { + if (!token) { + captchaAutoSubmitRef.current = false + setCaptcha((current) => ({ ...current, token: '' })) + return + } + + captchaAutoSubmitRef.current = true + setCaptcha((current) => ({ ...current, token })) + }, []) + /* ── Submit ───────────────────────────────────────────────────────────── */ const handleSubmit = useCallback( async (e) => { @@ -251,18 +265,36 @@ export default function CommentForm({ setErrors([]) try { - const { data } = await axios.post(resolvedSubmitUrl, { + const payload = { [contentField]: trimmed, parent_id: parentId || null, - }) + } + if (captcha.required) payload[captcha.inputName] = captcha.token || '' + + const { data } = await axios.post(resolvedSubmitUrl, payload) setContent('') setTab('write') + setCaptcha((current) => ({ ...current, required: false, token: '' })) onPosted?.(data.data) onCancelReply?.() } catch (err) { if (err.response?.status === 422) { - const fieldErrors = err.response.data?.errors ?? {} + const response = err.response.data ?? {} + const fieldErrors = response.errors ?? {} + if (response.requires_captcha) { + const challenge = response.captcha ?? {} + setCaptcha((current) => ({ + ...current, + required: true, + token: '', + nonce: current.nonce + 1, + provider: challenge.provider || response.captcha_provider || current.provider, + siteKey: challenge.siteKey || response.captcha_site_key || current.siteKey, + scriptUrl: challenge.scriptUrl || response.captcha_script_url || current.scriptUrl, + inputName: challenge.inputName || response.captcha_input || current.inputName, + })) + } const allErrors = [ ...(Array.isArray(fieldErrors[contentField]) ? fieldErrors[contentField] : []), ...Object.entries(fieldErrors) @@ -277,9 +309,19 @@ export default function CommentForm({ setSubmitting(false) } }, - [content, contentField, isLoggedIn, loginUrl, onPosted, parentId, onCancelReply, resolvedSubmitUrl], + [captcha, content, contentField, isLoggedIn, loginUrl, onPosted, parentId, onCancelReply, resolvedSubmitUrl], ) + // Turnstile is a gate in front of the existing submission. Once it returns + // a valid token, continue the original submit automatically so the user + // does not need to press the button a second time. + useEffect(() => { + if (!captcha.required || !captcha.token || submitting || !captchaAutoSubmitRef.current) return + + captchaAutoSubmitRef.current = false + handleSubmit({ preventDefault() {} }) + }, [captcha.required, captcha.token, handleSubmit, submitting]) + /* ── Logged-out state ─────────────────────────────────────────────────── */ if (!isLoggedIn) { return ( @@ -441,6 +483,20 @@ export default function CommentForm({ {/* Errors */} + {captcha.required && captcha.siteKey && ( +
+

Complete the captcha challenge to continue.

+ +
+ )} + {errors.length > 0 && (