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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user