Add comment spam classification and captcha checks.

Score artwork comments with local signatures plus Together AI, and optionally require Turnstile before posting.
This commit is contained in:
2026-09-20 14:48:50 +02:00
parent 37bacc1334
commit 586c44ba74
16 changed files with 858 additions and 135 deletions
@@ -0,0 +1,10 @@
<?php
namespace App\Contracts\Moderation;
use App\Data\Moderation\CommentSpamClassification;
interface CommentSpamClassifier
{
public function classify(string $content): CommentSpamClassification;
}
@@ -0,0 +1,15 @@
<?php
namespace App\Data\Moderation;
final class CommentSpamClassification
{
public function __construct(
public readonly bool $spam,
public readonly float $confidence,
public readonly string $reason,
public readonly string $provider,
public readonly string $model,
public readonly int $latencyMs,
) {}
}
@@ -3,15 +3,17 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Activity\UserActivityService;
use App\Models\ActivityEvent;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\User;
use App\Models\UserMention;
use App\Notifications\ArtworkCommentedNotification;
use App\Notifications\ArtworkMentionedNotification;
use App\Services\ContentSanitizer;
use App\Services\Activity\UserActivityService;
use App\Services\CommentReactionService;
use App\Services\ContentSanitizer;
use App\Services\Moderation\CommentSpamService;
use App\Support\AvatarUrl;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
@@ -31,7 +33,10 @@ class ArtworkCommentController extends Controller
{
private const MAX_LENGTH = 10_000;
public function __construct(private readonly CommentReactionService $commentReactions) {}
public function __construct(
private readonly CommentReactionService $commentReactions,
private readonly CommentSpamService $commentSpam,
) {}
// ─────────────────────────────────────────────────────────────────────────
// List
@@ -41,14 +46,14 @@ class ArtworkCommentController extends Controller
{
$artwork = Artwork::public()->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] ?? [],
];
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Middleware;
use Closure;
use cPad\Plugins\Forum\Services\Security\BotProtectionService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
class CommentCaptchaMiddleware
{
public function __construct(private readonly BotProtectionService $botProtection) {}
public function handle(Request $request, Closure $next): Response
{
if (! (bool) config('comment_spam.captcha.enabled', false)) {
return $next($request);
}
$assessment = $this->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);
}
}
+82 -67
View File
@@ -1,95 +1,110 @@
<?php
namespace App\Models;
use App\Observers\ArtworkCommentObserver;
use App\Services\ContentSanitizer;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* App\Models\ArtworkComment
*
* @property int $id
* @property int $artwork_id
* @property int $user_id
* @property string|null $content Legacy plain-text column
* @property string|null $raw_content User-submitted Markdown
* @property string|null $rendered_content Cached sanitized HTML
* @property bool $is_approved
* @property int $id
* @property int $artwork_id
* @property int $user_id
* @property string|null $content Legacy plain-text column
* @property string|null $raw_content User-submitted Markdown
* @property string|null $rendered_content Cached sanitized HTML
* @property bool $is_approved
* @property-read Artwork $artwork
* @property-read User $user
* @property-read \Illuminate\Database\Eloquent\Collection|CommentReaction[] $reactions
* @property-read Collection|CommentReaction[] $reactions
*/
#[ObservedBy([ArtworkCommentObserver::class])]
class ArtworkComment extends Model
{
use HasFactory, SoftDeletes;
use HasFactory, SoftDeletes;
protected $table = 'artwork_comments';
protected $table = 'artwork_comments';
protected $fillable = [
'legacy_id',
'artwork_id',
'user_id',
'parent_id',
'content',
'raw_content',
'rendered_content',
'is_approved',
];
protected $fillable = [
'legacy_id',
'artwork_id',
'user_id',
'parent_id',
'content',
'raw_content',
'rendered_content',
'is_approved',
'spam_score',
'spam_probability',
'spam_reason',
'moderation_source',
'moderated_at',
];
protected $casts = [
'is_approved' => '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);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CommentSpamSignature extends Model
{
public $timestamps = false;
protected $fillable = [
'content_hash', 'pattern_signature', 'source', 'reason',
'confidence', 'hit_count', 'reviewed_by', 'metadata', 'created_at',
];
protected $casts = [
'confidence' => 'integer',
'hit_count' => 'integer',
'metadata' => 'array',
'created_at' => 'datetime',
];
}
@@ -0,0 +1,179 @@
<?php
namespace App\Services\Moderation;
use App\Contracts\Moderation\CommentSpamClassifier;
use App\Models\ArtworkComment;
use App\Models\CommentSpamSignature;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Throwable;
class CommentSpamService
{
public function __construct(
private readonly ContentModerationProcessingService $processing,
private readonly CommentSpamClassifier $ai,
) {}
/**
* Classify after the comment exists, so the existing moderation queue can
* retain the full source context and admin review workflow.
*
* @return array{status:string, score:int, probability:int, source:string, reason:string}
*/
public function moderate(ArtworkComment $comment, User $user, ?string $mode = null): array
{
$mode ??= (string) config('comment_spam.mode', 'enforce');
$content = (string) ($comment->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 !== []);
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Services\Moderation;
use App\Contracts\Moderation\CommentSpamClassifier;
use App\Data\Moderation\CommentSpamClassification;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use RuntimeException;
final class TogetherCommentSpamClassifier implements CommentSpamClassifier
{
public function classify(string $content): CommentSpamClassification
{
$config = (array) config('comment_spam.ai', []);
$key = (string) ($config['api_key'] ?? '');
$model = (string) ($config['model'] ?? '');
if ($key === '' || $model === '') {
throw new RuntimeException('Together AI is not configured.');
}
if (! in_array($model, (array) ($config['allowed_models'] ?? []), true)) {
throw new RuntimeException('Unsupported Together AI comment spam model.');
}
$started = microtime(true);
$response = Http::timeout(min(5, max(1, (int) ($config['timeout'] ?? 5))))
->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),
);
}
}
+1
View File
@@ -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,
+34
View File
@@ -0,0 +1,34 @@
<?php
return [
'enabled' => (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'),
],
];
@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('artwork_comments', function (Blueprint $table): void {
$table->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',
]);
});
}
};
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('comment_spam_signatures')) {
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();
});
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');
});
}
};
@@ -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({
</div>
{/* Errors */}
{captcha.required && captcha.siteKey && (
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
<p className="mb-3 text-sm text-amber-100">Complete the captcha challenge to continue.</p>
<TurnstileField
key={`comment-captcha-${captcha.nonce}`}
provider={captcha.provider}
siteKey={captcha.siteKey}
scriptUrl={captcha.scriptUrl}
onToken={handleCaptchaToken}
className="rounded-lg border border-white/10 bg-black/20 p-3"
/>
</div>
)}
{errors.length > 0 && (
<ul className="space-y-1 rounded-xl border border-red-500/20 bg-red-500/[0.06] px-4 py-2.5" role="alert">
{errors.map((e, i) => (
@@ -455,7 +511,7 @@ export default function CommentForm({
<div className="flex justify-end">
<button
type="submit"
disabled={submitting || !content.trim()}
disabled={submitting || !content.trim() || (captcha.required && !captcha.token)}
className="rounded-full bg-accent px-6 py-2 text-sm font-semibold text-white shadow-lg shadow-accent/20 transition-all duration-200 hover:bg-accent/90 hover:shadow-xl hover:shadow-accent/25 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-deep disabled:pointer-events-none disabled:opacity-40 disabled:shadow-none"
>
{submitting ? (
@@ -7,6 +7,8 @@ const providerAdapters = {
return api.render(container, {
sitekey: siteKey,
theme,
appearance: 'always',
size: 'normal',
callback: (token) => onToken?.(token || ''),
'expired-callback': () => onToken?.(''),
'error-callback': () => onToken?.(''),
@@ -80,6 +82,15 @@ function loadCaptchaScript(src) {
const existing = document.querySelector(`script[src="${src}"]`)
if (existing) {
// The registration page may have loaded the same Turnstile script
// already. In that case its load event has fired before this promise
// was created, so waiting only for another load event would never
// resolve and the widget would remain an empty container.
if (window.turnstile || window.grecaptcha || window.hcaptcha) {
resolve()
return
}
if (existing.dataset.loaded === 'true') {
resolve()
return
@@ -109,6 +120,11 @@ function loadCaptchaScript(src) {
export default function TurnstileField({ provider = 'turnstile', siteKey, scriptUrl = '', onToken, theme = 'dark', className = '' }) {
const containerRef = useRef(null)
const widgetIdRef = useRef(null)
const onTokenRef = useRef(onToken)
useEffect(() => {
onTokenRef.current = onToken
}, [onToken])
useEffect(() => {
const adapter = providerAdapters[provider] || providerAdapters.turnstile
@@ -130,17 +146,17 @@ export default function TurnstileField({ provider = 'turnstile', siteKey, script
widgetIdRef.current = adapter.render(api, containerRef.current, {
siteKey,
theme,
onToken,
onToken: (token) => onTokenRef.current?.(token),
})
}
loadCaptchaScript(scriptUrl).catch(() => onToken?.('')).finally(() => {
loadCaptchaScript(scriptUrl).catch(() => onTokenRef.current?.('')).finally(() => {
const api = window[adapter.globalName]
if (typeof api?.ready === 'function') {
api.ready(mountWidget)
} else {
mountWidget()
}
// Do not call turnstile.ready() here. Cloudflare rejects ready() when
// api.js was loaded with async/defer (as it is on the registration
// page). Polling until render() exists works for both an already-loaded
// shared script and a script that is still loading.
mountWidget()
if (widgetIdRef.current === null) {
intervalId = window.setInterval(mountWidget, 250)
@@ -152,10 +168,10 @@ export default function TurnstileField({ provider = 'turnstile', siteKey, script
if (intervalId) {
window.clearInterval(intervalId)
}
adapter.cleanup(window[adapter.globalName], widgetIdRef.current, containerRef.current, onToken)
adapter.cleanup(window[adapter.globalName], widgetIdRef.current, containerRef.current, (token) => onTokenRef.current?.(token))
widgetIdRef.current = null
}
}, [className, onToken, provider, scriptUrl, siteKey, theme])
}, [provider, scriptUrl, siteKey, theme])
if (!siteKey) {
return null
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\CommentSpamSignature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('increments signature hit_count when a known spam comment is posted', function (): void {
config([
'comment_spam.enabled' => true,
'comment_spam.mode' => 'enforce',
'comment_spam.ai_enabled' => false,
'comment_spam.captcha.enabled' => false,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$content = 'copy paste this known spam signature';
$hash = hash('sha256', mb_strtolower((string) preg_replace('/\s+/u', ' ', trim($content))));
$signature = CommentSpamSignature::query()->create([
'content_hash' => $hash,
'source' => 'spam',
'reason' => 'Known promotional spam.',
'confidence' => 100,
'hit_count' => 0,
'created_at' => now(),
]);
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/comments", [
'content' => $content,
])
->assertCreated();
expect((int) $signature->fresh()->hit_count)->toBe(1);
$comment = ArtworkComment::query()->latest('id')->first();
expect($comment)->not->toBeNull()
->and((bool) $comment->is_approved)->toBeFalse()
->and((string) $comment->moderation_source)->toBe('signature');
});
@@ -0,0 +1,66 @@
<?php
use App\Services\Moderation\TogetherCommentSpamClassifier;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function (): void {
config([
'comment_spam.ai.api_key' => 'test-key',
'comment_spam.ai.base_url' => 'https://api.together.test/v1',
'comment_spam.ai.model' => 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
]);
});
it('classifies using the strict Together JSON contract', function (string $model): void {
config(['comment_spam.ai.model' => $model]);
Http::fake([
'https://api.together.test/*' => Http::response([
'choices' => [['message' => ['content' => '{"spam":true,"confidence":0.95,"reason":"Unsolicited promotion."}']]],
]),
]);
$result = app(TogetherCommentSpamClassifier::class)->classify('Buy now at example.test');
expect($result->spam)->toBeTrue()
->and($result->confidence)->toBe(0.95)
->and($result->provider)->toBe('together')
->and($result->model)->toBe($model);
})->with([
'Llama' => ['meta-llama/Llama-3.2-3B-Instruct-Turbo'],
'Bonsai' => ['Prism-ML/Ternary-Bonsai-27B'],
]);
it('rejects an unsupported configured model before making an HTTP request', function (): void {
config(['comment_spam.ai.model' => 'some/unsupported-model']);
Http::fake();
expect(fn () => app(TogetherCommentSpamClassifier::class)->classify('hello'))
->toThrow(RuntimeException::class, 'Unsupported Together AI comment spam model.');
Http::assertNothingSent();
});
it('rejects malformed or out of range classifier output', function (array $json): void {
Http::fake([
'https://api.together.test/*' => Http::response([
'choices' => [['message' => ['content' => json_encode($json)]]],
]),
]);
expect(fn () => app(TogetherCommentSpamClassifier::class)->classify('hello'))
->toThrow(RuntimeException::class);
})->with([
[['spam' => 'true', 'confidence' => 0.9, 'reason' => 'bad type']],
[['spam' => true, 'confidence' => 1.1, 'reason' => 'out of range']],
[['spam' => true, 'confidence' => 0.9]],
]);
it('fails closed when Together is not configured', function (): void {
config(['comment_spam.ai.api_key' => '']);
expect(fn () => app(TogetherCommentSpamClassifier::class)->classify('hello'))
->toThrow(RuntimeException::class);
});