Require review for untrusted accounts, add admin artwork review APIs, and keep queued or auto-trusted publishes from counting as established history.
98 lines
3.2 KiB
PHP
98 lines
3.2 KiB
PHP
<?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);
|
|
}
|
|
}
|