Send new artwork uploads through a trust-based review policy.
Require review for untrusted accounts, add admin artwork review APIs, and keep queued or auto-trusted publishes from counting as established history.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
|
||||
final class ArtworkUploadPolicy
|
||||
{
|
||||
/** @return array{requires_review: bool, reasons: array<int, string>, approved_uploads: int} */
|
||||
public function assess(User $user, ?Artwork $artwork = null): array
|
||||
{
|
||||
if ($user->isAdmin()) {
|
||||
return ['requires_review' => false, 'reasons' => [], 'approved_uploads' => PHP_INT_MAX];
|
||||
}
|
||||
|
||||
$approvedUploads = $this->approvedArtworkCount($user, $artwork);
|
||||
$minimumApproved = max(1, (int) config('uploads.moderation.minimum_approved_uploads', 5));
|
||||
|
||||
$reasons = [];
|
||||
|
||||
if ($approvedUploads < $minimumApproved) {
|
||||
$reasons[] = 'insufficient_approved_uploads';
|
||||
|
||||
if ($user->email_verified_at === null) {
|
||||
$reasons[] = 'email_not_verified';
|
||||
}
|
||||
|
||||
$minimumAgeDays = max(0, (int) config('uploads.moderation.minimum_account_age_days', 7));
|
||||
if ($user->created_at?->gt(now()->subDays($minimumAgeDays))) {
|
||||
$reasons[] = 'new_account';
|
||||
}
|
||||
|
||||
if ((int) ($user->level ?? 1) <= (int) config('uploads.moderation.maximum_untrusted_level', 1)) {
|
||||
$reasons[] = 'low_level';
|
||||
}
|
||||
|
||||
if ((int) ($user->bot_risk_score ?? 0) >= (int) config('uploads.moderation.bot_risk_review_threshold', 40)) {
|
||||
$reasons[] = 'bot_risk';
|
||||
}
|
||||
|
||||
if ((int) ($user->spam_reports ?? 0) > 0) {
|
||||
$reasons[] = 'spam_reports';
|
||||
}
|
||||
}
|
||||
|
||||
if ($artwork && $this->looksPromotional($artwork)) {
|
||||
$reasons[] = 'promotional_content';
|
||||
}
|
||||
|
||||
return [
|
||||
'requires_review' => $reasons !== [],
|
||||
'reasons' => array_values(array_unique($reasons)),
|
||||
'approved_uploads' => $approvedUploads,
|
||||
];
|
||||
}
|
||||
|
||||
private function approvedArtworkCount(User $user, ?Artwork $artwork): int
|
||||
{
|
||||
$query = $user->artworks()
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->where(function ($status): void {
|
||||
$status->whereNull('artwork_status')
|
||||
->orWhereNotIn('artwork_status', ['review', 'rejected', 'scheduled', 'draft']);
|
||||
})
|
||||
->where(function ($source): void {
|
||||
$source->whereNull('approval_source')
|
||||
->orWhere('approval_source', 'moderator');
|
||||
});
|
||||
|
||||
if ($artwork?->exists) {
|
||||
$query->whereKeyNot($artwork->getKey());
|
||||
}
|
||||
|
||||
return (int) $query->count();
|
||||
}
|
||||
|
||||
private function looksPromotional(Artwork $artwork): bool
|
||||
{
|
||||
$text = strtolower(implode(' ', [
|
||||
(string) $artwork->title,
|
||||
(string) $artwork->description,
|
||||
(string) $artwork->file_name,
|
||||
]));
|
||||
|
||||
foreach ((array) config('uploads.moderation.promotional_patterns', []) as $pattern) {
|
||||
if ($pattern !== '' && str_contains($text, strtolower((string) $pattern))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return (bool) preg_match('/(?:https?:\/\/|www\.|\b[a-z0-9-]+\.(?:com|net|org|io|co)\b)/i', $text);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Data\Moderation\ModerationResultData;
|
||||
use App\Enums\ModerationEscalationStatus;
|
||||
use App\Enums\ModerationContentType;
|
||||
use App\Enums\ModerationEscalationStatus;
|
||||
use App\Enums\ModerationStatus;
|
||||
use App\Models\ContentModerationAiSuggestion;
|
||||
use App\Models\ContentModerationFinding;
|
||||
@@ -14,8 +14,7 @@ class ContentModerationPersistenceService
|
||||
public function __construct(
|
||||
private readonly ContentModerationReviewService $review,
|
||||
private readonly ContentModerationActionLogService $actionLogs,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function shouldQueue(ModerationResultData $result): bool
|
||||
{
|
||||
@@ -33,7 +32,7 @@ class ContentModerationPersistenceService
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $context
|
||||
* @return array{finding:?ContentModerationFinding, created:bool, updated:bool}
|
||||
*/
|
||||
public function persist(ModerationResultData $result, array $context): array
|
||||
@@ -56,7 +55,7 @@ class ContentModerationPersistenceService
|
||||
return ['finding' => null, 'created' => false, 'updated' => false];
|
||||
}
|
||||
|
||||
$finding = $existing ?? new ContentModerationFinding();
|
||||
$finding = $existing ?? new ContentModerationFinding;
|
||||
$isNew = ! $finding->exists;
|
||||
|
||||
$finding->fill([
|
||||
@@ -144,10 +143,16 @@ class ContentModerationPersistenceService
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function applyAutomatedActionIfNeeded(ContentModerationFinding $finding, ModerationResultData $result, array $context): bool
|
||||
{
|
||||
if (($context['comment_spam_mode'] ?? null) === 'observe') {
|
||||
$finding->forceFill(['auto_action_taken' => 'observe_only'])->save();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $result->autoHideRecommended) {
|
||||
return false;
|
||||
}
|
||||
@@ -179,4 +184,4 @@ class ContentModerationPersistenceService
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user