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,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class ArtworkModerationController extends Controller
|
||||
{
|
||||
public function pending(): JsonResponse
|
||||
{
|
||||
$artworks = Artwork::query()
|
||||
->with([
|
||||
'user:id,name,username,level,email',
|
||||
'tags:id,name,slug',
|
||||
'categories:id,name,slug',
|
||||
])
|
||||
->where('artwork_status', 'review')
|
||||
->where('is_approved', false)
|
||||
->latest('created_at')
|
||||
->limit(100)
|
||||
->get([
|
||||
'id', 'user_id', 'title', 'description', 'hash', 'thumb_ext',
|
||||
'artwork_status', 'moderation_note', 'created_at', 'slug',
|
||||
'is_mature', 'maturity_status', 'visibility',
|
||||
])
|
||||
->map(fn (Artwork $artwork): array => $this->present($artwork))
|
||||
->values();
|
||||
|
||||
return response()->json(['data' => $artworks], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function approve(int $id, Request $request, NotificationService $notifications): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::query()->with('user')->where('artwork_status', 'review')->find($id);
|
||||
if (! $artwork) {
|
||||
return response()->json(['message' => 'Artwork not found in review queue.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$artwork->forceFill([
|
||||
'is_approved' => true,
|
||||
'is_public' => $artwork->visibility !== Artwork::VISIBILITY_PRIVATE,
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now(),
|
||||
'approval_source' => 'moderator',
|
||||
'moderated_at' => now(),
|
||||
'moderated_by' => $request->user()->id,
|
||||
'moderation_note' => $request->input('note'),
|
||||
])->save();
|
||||
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
|
||||
if ($artwork->user) {
|
||||
$notifications->notifyArtworkApproved($artwork->user, $request->user(), $artwork);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'id' => $artwork->id, 'status' => 'published']);
|
||||
}
|
||||
|
||||
public function reject(int $id, Request $request, NotificationService $notifications): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::query()->with('user')->where('artwork_status', 'review')->find($id);
|
||||
if (! $artwork) {
|
||||
return response()->json(['message' => 'Artwork not found in review queue.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$note = (string) $request->input('note', 'Rejected during upload moderation.');
|
||||
|
||||
$artwork->forceFill([
|
||||
'is_approved' => false,
|
||||
'is_public' => false,
|
||||
'artwork_status' => 'rejected',
|
||||
'published_at' => null,
|
||||
'moderated_at' => now(),
|
||||
'moderated_by' => $request->user()->id,
|
||||
'moderation_note' => $note,
|
||||
])->save();
|
||||
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
|
||||
if ($artwork->user) {
|
||||
$notifications->notifyArtworkRejected($artwork->user, $request->user(), $artwork, $note);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'id' => $artwork->id, 'status' => 'rejected']);
|
||||
}
|
||||
|
||||
private function present(Artwork $artwork): array
|
||||
{
|
||||
$previewUrl = $artwork->thumbUrl('md') ?: $artwork->thumbUrl('sm');
|
||||
$previewLgUrl = $artwork->thumbUrl('lg') ?: $previewUrl;
|
||||
|
||||
return [
|
||||
'id' => (int) $artwork->id,
|
||||
'title' => (string) ($artwork->title ?: '(untitled artwork)'),
|
||||
'description' => (string) ($artwork->description ?? ''),
|
||||
'type' => 'artwork',
|
||||
'preview_url' => $previewUrl,
|
||||
'preview_lg_url' => $previewLgUrl,
|
||||
'tags' => $artwork->tags
|
||||
->map(fn ($tag): string => trim((string) ($tag->name ?: $tag->slug)))
|
||||
->filter()
|
||||
->values()
|
||||
->all(),
|
||||
'categories' => $artwork->categories
|
||||
->map(fn ($category): string => trim((string) ($category->name ?: $category->slug)))
|
||||
->filter()
|
||||
->values()
|
||||
->all(),
|
||||
'user' => $artwork->user ? [
|
||||
'id' => (int) $artwork->user->id,
|
||||
'name' => (string) $artwork->user->name,
|
||||
'username' => $artwork->user->username,
|
||||
] : null,
|
||||
'slug' => $artwork->slug,
|
||||
'is_mature' => (bool) $artwork->is_mature,
|
||||
'maturity_status' => $artwork->maturity_status,
|
||||
'visibility' => $artwork->visibility,
|
||||
'moderation_note' => $artwork->moderation_note,
|
||||
'created_at' => optional($artwork->created_at)?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -8,33 +8,51 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Upload;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
final class UploadModerationController extends Controller
|
||||
{
|
||||
public function pending(): JsonResponse
|
||||
{
|
||||
$uploads = Upload::query()
|
||||
->with(['user:id,name,username', 'category:id,name,slug'])
|
||||
->where('status', 'draft')
|
||||
->where('moderation_status', 'pending')
|
||||
->orderBy('created_at')
|
||||
->get([
|
||||
'id',
|
||||
'user_id',
|
||||
'type',
|
||||
'status',
|
||||
'processing_state',
|
||||
'title',
|
||||
'preview_path',
|
||||
'created_at',
|
||||
'moderation_status',
|
||||
]);
|
||||
->get()
|
||||
->map(fn (Upload $upload): array => $this->present($upload))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $uploads,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function preview(string $id): StreamedResponse|JsonResponse
|
||||
{
|
||||
$upload = Upload::query()
|
||||
->where('status', 'draft')
|
||||
->where('moderation_status', 'pending')
|
||||
->find($id);
|
||||
|
||||
if (! $upload) {
|
||||
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$path = $this->safePreviewPath($upload);
|
||||
if ($path === null || ! Storage::disk('local')->exists($path)) {
|
||||
return response()->json(['message' => 'Preview not found.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return Storage::disk('local')->response($path, 'preview.webp', [
|
||||
'Content-Type' => 'image/webp',
|
||||
'Cache-Control' => 'private, max-age=120',
|
||||
]);
|
||||
}
|
||||
|
||||
public function approve(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$upload = Upload::query()->find($id);
|
||||
@@ -80,4 +98,56 @@ final class UploadModerationController extends Controller
|
||||
'moderation_status' => (string) $upload->moderation_status,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
private function present(Upload $upload): array
|
||||
{
|
||||
$tags = collect($upload->tags ?? [])
|
||||
->map(function (mixed $tag): string {
|
||||
if (is_array($tag)) {
|
||||
return trim((string) ($tag['name'] ?? $tag['slug'] ?? ''));
|
||||
}
|
||||
|
||||
return trim((string) $tag);
|
||||
})
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$hasPreview = $this->safePreviewPath($upload) !== null;
|
||||
|
||||
return [
|
||||
'id' => (string) $upload->id,
|
||||
'title' => (string) ($upload->title ?: '(untitled upload)'),
|
||||
'description' => (string) ($upload->description ?? ''),
|
||||
'type' => (string) ($upload->type ?? 'image'),
|
||||
'preview_url' => $hasPreview
|
||||
? URL::temporarySignedRoute('api.admin.uploads.preview', now()->addMinutes(30), ['id' => $upload->id])
|
||||
: null,
|
||||
'preview_lg_url' => $hasPreview
|
||||
? URL::temporarySignedRoute('api.admin.uploads.preview', now()->addMinutes(30), ['id' => $upload->id])
|
||||
: null,
|
||||
'tags' => $tags,
|
||||
'categories' => $upload->category?->name ? [(string) $upload->category->name] : [],
|
||||
'user' => $upload->user ? [
|
||||
'id' => (int) $upload->user->id,
|
||||
'name' => (string) $upload->user->name,
|
||||
'username' => $upload->user->username,
|
||||
] : null,
|
||||
'nsfw' => (bool) $upload->nsfw,
|
||||
'license' => $upload->license,
|
||||
'created_at' => optional($upload->created_at)?->toISOString(),
|
||||
];
|
||||
}
|
||||
|
||||
private function safePreviewPath(Upload $upload): ?string
|
||||
{
|
||||
$path = str_replace('\\', '/', ltrim((string) $upload->preview_path, '/'));
|
||||
$expectedPrefix = 'tmp/drafts/'.$upload->id.'/';
|
||||
|
||||
if ($path === '' || str_contains($path, '..') || ! str_starts_with($path, $expectedPrefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
final class NewArtworkReviewNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly Artwork $artwork,
|
||||
private readonly User $uploader,
|
||||
private readonly array $reasons,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject('Skinbase: new artwork awaiting review')
|
||||
->greeting('New artwork review')
|
||||
->line(sprintf('“%s” by %s is being held before publication.', $this->artwork->title ?: 'Untitled artwork', $this->uploader->username ?: $this->uploader->name))
|
||||
->line('Reasons: '.implode(', ', $this->reasons))
|
||||
->action('Open moderation queue', url('/moderation/uploads'))
|
||||
->line('The artwork is not public until it is approved.');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +359,60 @@ final class NotificationService
|
||||
]);
|
||||
}
|
||||
|
||||
public function notifyArtworkApproved(User $recipient, User $actor, \App\Models\Artwork $artwork): ?Notification
|
||||
{
|
||||
if ($recipient->id === $actor->id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$title = (string) ($artwork->title ?: 'Untitled artwork');
|
||||
|
||||
return Notification::query()->create([
|
||||
'user_id' => (int) $recipient->id,
|
||||
'type' => 'artwork_approved',
|
||||
'data' => [
|
||||
'type' => 'artwork_approved',
|
||||
'actor_id' => (int) $actor->id,
|
||||
'actor_name' => $actor->name,
|
||||
'actor_username' => $actor->username,
|
||||
'message' => 'Your artwork "'.$title.'" was approved and is now live.',
|
||||
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: $artwork->id]),
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'artwork_title' => $title,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function notifyArtworkRejected(User $recipient, User $actor, \App\Models\Artwork $artwork, ?string $note = null): ?Notification
|
||||
{
|
||||
if ($recipient->id === $actor->id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$title = (string) ($artwork->title ?: 'Untitled artwork');
|
||||
$message = 'Your artwork "'.$title.'" was not approved.';
|
||||
$note = trim((string) $note);
|
||||
if ($note !== '') {
|
||||
$message .= ' Reason: '.$note;
|
||||
}
|
||||
|
||||
return Notification::query()->create([
|
||||
'user_id' => (int) $recipient->id,
|
||||
'type' => 'artwork_rejected',
|
||||
'data' => [
|
||||
'type' => 'artwork_rejected',
|
||||
'actor_id' => (int) $actor->id,
|
||||
'actor_name' => $actor->name,
|
||||
'actor_username' => $actor->username,
|
||||
'message' => $message,
|
||||
'url' => route('studio.artworks.edit', ['id' => $artwork->id]),
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'artwork_title' => $title,
|
||||
'note' => $note !== '' ? $note : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function notifyGroupPostPublished(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupPost $post): ?Notification
|
||||
{
|
||||
if ($recipient->id === $actor->id) {
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Jobs\AutoTagArtworkJob;
|
||||
use App\Jobs\DetectArtworkMaturityJob;
|
||||
use App\Jobs\GenerateArtworkEmbeddingJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Moderation\ArtworkUploadPolicy;
|
||||
use App\Models\UploadBatch;
|
||||
use App\Models\UploadBatchItem;
|
||||
use App\Models\User;
|
||||
@@ -392,8 +393,24 @@ final class UploadQueueService
|
||||
}
|
||||
|
||||
$artwork = $item->artwork;
|
||||
$policy = app(ArtworkUploadPolicy::class)->assess($item->user ?? $artwork->user, $artwork);
|
||||
if ($policy['requires_review']) {
|
||||
$artwork->forceFill([
|
||||
'is_public' => false,
|
||||
'is_approved' => false,
|
||||
'artwork_status' => 'review',
|
||||
'approval_source' => null,
|
||||
'moderation_note' => implode(', ', $policy['reasons']),
|
||||
])->saveQuietly();
|
||||
$item->forceFill([
|
||||
'status' => UploadBatchItem::STATUS_NEEDS_REVIEW,
|
||||
'is_ready_to_publish' => false,
|
||||
])->save();
|
||||
return;
|
||||
}
|
||||
$artwork->forceFill([
|
||||
'is_approved' => true,
|
||||
'approval_source' => 'trusted_auto',
|
||||
'visibility' => $artwork->visibility ?: Artwork::VISIBILITY_PUBLIC,
|
||||
])->saveQuietly();
|
||||
|
||||
@@ -428,7 +445,8 @@ final class UploadQueueService
|
||||
]);
|
||||
}
|
||||
|
||||
$item->artwork->categories()->sync([$categoryId]);
|
||||
app(\App\Services\Artworks\ArtworkCategoryService::class)
|
||||
->sync($item->artwork, $categoryId, [], 'user');
|
||||
$this->refreshItem((int) $item->id);
|
||||
}
|
||||
|
||||
@@ -790,4 +808,4 @@ final class UploadQueueService
|
||||
|
||||
return $normalized === '' ? null : Str::limit($normalized, 65535, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user