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 App\Models\Upload;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
final class UploadModerationController extends Controller
|
final class UploadModerationController extends Controller
|
||||||
{
|
{
|
||||||
public function pending(): JsonResponse
|
public function pending(): JsonResponse
|
||||||
{
|
{
|
||||||
$uploads = Upload::query()
|
$uploads = Upload::query()
|
||||||
|
->with(['user:id,name,username', 'category:id,name,slug'])
|
||||||
->where('status', 'draft')
|
->where('status', 'draft')
|
||||||
->where('moderation_status', 'pending')
|
->where('moderation_status', 'pending')
|
||||||
->orderBy('created_at')
|
->orderBy('created_at')
|
||||||
->get([
|
->get()
|
||||||
'id',
|
->map(fn (Upload $upload): array => $this->present($upload))
|
||||||
'user_id',
|
->values();
|
||||||
'type',
|
|
||||||
'status',
|
|
||||||
'processing_state',
|
|
||||||
'title',
|
|
||||||
'preview_path',
|
|
||||||
'created_at',
|
|
||||||
'moderation_status',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $uploads,
|
'data' => $uploads,
|
||||||
], Response::HTTP_OK);
|
], 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
|
public function approve(string $id, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$upload = Upload::query()->find($id);
|
$upload = Upload::query()->find($id);
|
||||||
@@ -80,4 +98,56 @@ final class UploadModerationController extends Controller
|
|||||||
'moderation_status' => (string) $upload->moderation_status,
|
'moderation_status' => (string) $upload->moderation_status,
|
||||||
], Response::HTTP_OK);
|
], 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;
|
namespace App\Services\Moderation;
|
||||||
|
|
||||||
use App\Data\Moderation\ModerationResultData;
|
use App\Data\Moderation\ModerationResultData;
|
||||||
use App\Enums\ModerationEscalationStatus;
|
|
||||||
use App\Enums\ModerationContentType;
|
use App\Enums\ModerationContentType;
|
||||||
|
use App\Enums\ModerationEscalationStatus;
|
||||||
use App\Enums\ModerationStatus;
|
use App\Enums\ModerationStatus;
|
||||||
use App\Models\ContentModerationAiSuggestion;
|
use App\Models\ContentModerationAiSuggestion;
|
||||||
use App\Models\ContentModerationFinding;
|
use App\Models\ContentModerationFinding;
|
||||||
@@ -14,8 +14,7 @@ class ContentModerationPersistenceService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ContentModerationReviewService $review,
|
private readonly ContentModerationReviewService $review,
|
||||||
private readonly ContentModerationActionLogService $actionLogs,
|
private readonly ContentModerationActionLogService $actionLogs,
|
||||||
) {
|
) {}
|
||||||
}
|
|
||||||
|
|
||||||
public function shouldQueue(ModerationResultData $result): bool
|
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}
|
* @return array{finding:?ContentModerationFinding, created:bool, updated:bool}
|
||||||
*/
|
*/
|
||||||
public function persist(ModerationResultData $result, array $context): array
|
public function persist(ModerationResultData $result, array $context): array
|
||||||
@@ -56,7 +55,7 @@ class ContentModerationPersistenceService
|
|||||||
return ['finding' => null, 'created' => false, 'updated' => false];
|
return ['finding' => null, 'created' => false, 'updated' => false];
|
||||||
}
|
}
|
||||||
|
|
||||||
$finding = $existing ?? new ContentModerationFinding();
|
$finding = $existing ?? new ContentModerationFinding;
|
||||||
$isNew = ! $finding->exists;
|
$isNew = ! $finding->exists;
|
||||||
|
|
||||||
$finding->fill([
|
$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
|
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) {
|
if (! $result->autoHideRecommended) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -179,4 +184,4 @@ class ContentModerationPersistenceService
|
|||||||
|
|
||||||
return true;
|
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
|
public function notifyGroupPostPublished(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupPost $post): ?Notification
|
||||||
{
|
{
|
||||||
if ($recipient->id === $actor->id) {
|
if ($recipient->id === $actor->id) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Jobs\AutoTagArtworkJob;
|
|||||||
use App\Jobs\DetectArtworkMaturityJob;
|
use App\Jobs\DetectArtworkMaturityJob;
|
||||||
use App\Jobs\GenerateArtworkEmbeddingJob;
|
use App\Jobs\GenerateArtworkEmbeddingJob;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
|
use App\Services\Moderation\ArtworkUploadPolicy;
|
||||||
use App\Models\UploadBatch;
|
use App\Models\UploadBatch;
|
||||||
use App\Models\UploadBatchItem;
|
use App\Models\UploadBatchItem;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -392,8 +393,24 @@ final class UploadQueueService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$artwork = $item->artwork;
|
$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([
|
$artwork->forceFill([
|
||||||
'is_approved' => true,
|
'is_approved' => true,
|
||||||
|
'approval_source' => 'trusted_auto',
|
||||||
'visibility' => $artwork->visibility ?: Artwork::VISIBILITY_PUBLIC,
|
'visibility' => $artwork->visibility ?: Artwork::VISIBILITY_PUBLIC,
|
||||||
])->saveQuietly();
|
])->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);
|
$this->refreshItem((int) $item->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,4 +808,4 @@ final class UploadQueueService
|
|||||||
|
|
||||||
return $normalized === '' ? null : Str::limit($normalized, 65535, '');
|
return $normalized === '' ? null : Str::limit($normalized, 65535, '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ return [
|
|||||||
|
|
||||||
'readonly_backup_originals_root' => env('ARTWORKS_READONLY_BACKUP_ORIGINALS_ROOT', '/opt/www/virtual/files/cdn/artworks/original'),
|
'readonly_backup_originals_root' => env('ARTWORKS_READONLY_BACKUP_ORIGINALS_ROOT', '/opt/www/virtual/files/cdn/artworks/original'),
|
||||||
|
|
||||||
|
// Optional filename-based legacy tree. The readonly backup root above is
|
||||||
|
// hash-based and must never be treated as this legacy tree.
|
||||||
|
'legacy_originals_root' => env('ARTWORKS_LEGACY_ORIGINALS_ROOT', ''),
|
||||||
|
|
||||||
'object_storage' => [
|
'object_storage' => [
|
||||||
'disk' => env('ARTWORKS_OBJECT_DISK', 's3'),
|
'disk' => env('ARTWORKS_OBJECT_DISK', 's3'),
|
||||||
'prefix' => env('ARTWORKS_OBJECT_PREFIX', 'artworks'),
|
'prefix' => env('ARTWORKS_OBJECT_PREFIX', 'artworks'),
|
||||||
@@ -154,4 +158,16 @@ return [
|
|||||||
'enabled' => env('UPLOAD_SCAN_ENABLED', false),
|
'enabled' => env('UPLOAD_SCAN_ENABLED', false),
|
||||||
'command' => env('UPLOAD_SCAN_COMMAND', []),
|
'command' => env('UPLOAD_SCAN_COMMAND', []),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'moderation' => [
|
||||||
|
'minimum_account_age_days' => (int) env('UPLOAD_MIN_ACCOUNT_AGE_DAYS', 7),
|
||||||
|
'minimum_approved_uploads' => (int) env('UPLOAD_MIN_APPROVED_UPLOADS', 5),
|
||||||
|
'maximum_untrusted_level' => (int) env('UPLOAD_MAX_UNTRUSTED_LEVEL', 1),
|
||||||
|
'bot_risk_review_threshold' => (int) env('UPLOAD_BOT_RISK_REVIEW_THRESHOLD', 40),
|
||||||
|
'notify_email' => env('UPLOAD_MODERATION_NOTIFY_EMAIL', env('SECURITY_REPORT_NOTIFY_EMAIL', env('MAIL_FROM_ADDRESS'))),
|
||||||
|
'promotional_patterns' => [
|
||||||
|
'buy now', 'best price', 'limited offer', 'contact us', 'whatsapp',
|
||||||
|
'casino', 'cheap backlinks', 'seo service', 'promo code', 'discount',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
Schema::table('artworks', function (Blueprint $table): void {
|
||||||
|
$table->string('approval_source', 24)->nullable()->after('is_approved')->index();
|
||||||
|
$table->timestamp('moderated_at')->nullable()->after('approval_source');
|
||||||
|
$table->unsignedBigInteger('moderated_by')->nullable()->after('moderated_at')->index();
|
||||||
|
$table->string('moderation_note', 1000)->nullable()->after('moderated_by');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('artworks', function (Blueprint $table): void {
|
||||||
|
$table->dropIndex(['approval_source']);
|
||||||
|
$table->dropIndex(['moderated_by']);
|
||||||
|
$table->dropColumn(['approval_source', 'moderated_at', 'moderated_by', 'moderation_note']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,18 +1,98 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
function tagList(item) {
|
||||||
|
return Array.isArray(item?.tags) ? item.tags.filter(Boolean) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryList(item) {
|
||||||
|
return Array.isArray(item?.categories) ? item.categories.filter(Boolean) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function creatorName(item) {
|
||||||
|
return item?.user?.username || item?.user?.name || 'Unknown creator'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMature(item) {
|
||||||
|
return Boolean(item?.nsfw || item?.is_mature)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewImage({ src, alt, className }) {
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFailed(false)
|
||||||
|
}, [src])
|
||||||
|
|
||||||
|
if (!src || failed) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-center bg-white/5 text-xs text-white/40 ${className}`}>
|
||||||
|
No preview
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={className}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ children, tone = 'slate' }) {
|
||||||
|
const tones = {
|
||||||
|
slate: 'border-white/10 bg-white/5 text-white/70',
|
||||||
|
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
|
||||||
|
rose: 'border-rose-300/20 bg-rose-400/10 text-rose-100',
|
||||||
|
emerald: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${tones[tone] || tones.slate}`}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminUploadQueue() {
|
export default function AdminUploadQueue() {
|
||||||
const [items, setItems] = useState([])
|
const [items, setItems] = useState([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [notes, setNotes] = useState({})
|
const [notes, setNotes] = useState({})
|
||||||
|
const [selected, setSelected] = useState(null)
|
||||||
|
const [busyId, setBusyId] = useState(null)
|
||||||
|
|
||||||
const loadPending = async () => {
|
const loadPending = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await window.axios.get('/api/admin/uploads/pending')
|
const [legacyResponse, artworkResponse] = await Promise.all([
|
||||||
setItems(Array.isArray(response?.data?.data) ? response.data.data : [])
|
window.axios.get('/api/admin/uploads/pending'),
|
||||||
|
window.axios.get('/api/admin/artwork-review/pending'),
|
||||||
|
])
|
||||||
|
const legacyItems = Array.isArray(legacyResponse?.data?.data)
|
||||||
|
? legacyResponse.data.data.map((item) => ({ ...item, queue: 'upload' }))
|
||||||
|
: []
|
||||||
|
const artworkItems = Array.isArray(artworkResponse?.data?.data)
|
||||||
|
? artworkResponse.data.data.map((item) => ({ ...item, queue: 'artwork' }))
|
||||||
|
: []
|
||||||
|
setItems([...artworkItems, ...legacyItems])
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
setError(loadError?.response?.data?.message || 'Failed to load moderation queue.')
|
setError(loadError?.response?.data?.message || 'Failed to load moderation queue.')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -24,18 +104,130 @@ export default function AdminUploadQueue() {
|
|||||||
loadPending()
|
loadPending()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const moderate = async (id, action) => {
|
useEffect(() => {
|
||||||
|
if (!selected) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setSelected(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [selected])
|
||||||
|
|
||||||
|
const moderate = async (item, action) => {
|
||||||
|
const id = item?.id
|
||||||
|
if (!id || busyId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusyId(id)
|
||||||
try {
|
try {
|
||||||
const payload = { note: String(notes[id] || '') }
|
const payload = { note: String(notes[id] || '') }
|
||||||
await window.axios.post(`/api/admin/uploads/${id}/${action}`, payload)
|
const base = item.queue === 'artwork' ? '/api/admin/artwork-review' : '/api/admin/uploads'
|
||||||
setItems((prev) => prev.filter((item) => item.id !== id))
|
await window.axios.post(`${base}/${id}/${action}`, payload)
|
||||||
|
setItems((prev) => prev.filter((candidate) => !(candidate.id === id && candidate.queue === item.queue)))
|
||||||
|
setSelected((current) => (current?.id === id && current?.queue === item.queue ? null : current))
|
||||||
} catch (moderateError) {
|
} catch (moderateError) {
|
||||||
setError(moderateError?.response?.data?.message || `Failed to ${action} upload.`)
|
setError(moderateError?.response?.data?.message || `Failed to ${action} upload.`)
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderMeta = (item, { compact = false } = {}) => {
|
||||||
|
const tags = tagList(item)
|
||||||
|
const categories = categoryList(item)
|
||||||
|
const createdAt = formatDate(item.created_at)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="text-sm font-medium text-white">{item.title || '(untitled upload)'}</div>
|
||||||
|
<Badge tone={item.queue === 'artwork' ? 'emerald' : 'slate'}>
|
||||||
|
{item.queue === 'artwork' ? 'Artwork' : 'Draft upload'}
|
||||||
|
</Badge>
|
||||||
|
{isMature(item) ? <Badge tone="rose">NSFW</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-white/65">
|
||||||
|
{creatorName(item)} · {item.type} · {item.id}
|
||||||
|
</div>
|
||||||
|
{createdAt ? <div className="mt-1 text-[11px] text-white/40">{createdAt}</div> : null}
|
||||||
|
{item.description ? (
|
||||||
|
<p className={`mt-2 text-xs leading-5 text-white/70 ${compact ? 'line-clamp-3' : 'whitespace-pre-wrap'}`}>
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-xs text-white/40">No description</p>
|
||||||
|
)}
|
||||||
|
{categories.length > 0 ? (
|
||||||
|
<div className="mt-2 text-[11px] text-amber-200/80">{categories.join(' · ')}</div>
|
||||||
|
) : null}
|
||||||
|
{tags.length > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{(compact ? tags.slice(0, 10) : tags).map((tag) => (
|
||||||
|
<span key={tag} className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-white/70">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 text-[11px] text-white/40">No tags</div>
|
||||||
|
)}
|
||||||
|
{item.moderation_note ? (
|
||||||
|
<div className="mt-2 text-[11px] text-sky-200/80">Review reasons: {item.moderation_note}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderActions = (item) => (
|
||||||
|
<div className="w-full max-w-sm space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
aria-label={`Moderation note for ${item.id}`}
|
||||||
|
value={notes[item.id] || ''}
|
||||||
|
onChange={(event) => setNotes((prev) => ({ ...prev, [item.id]: event.target.value }))}
|
||||||
|
placeholder="Moderation note"
|
||||||
|
className="w-full rounded-lg border border-white/15 bg-white/10 px-3 py-2 text-xs text-white"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Open details for ${item.id}`}
|
||||||
|
onClick={() => setSelected(item)}
|
||||||
|
className="rounded-lg border border-white/20 px-3 py-2 text-xs font-semibold text-white"
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Approve upload ${item.id}`}
|
||||||
|
disabled={busyId === item.id}
|
||||||
|
onClick={() => moderate(item, 'approve')}
|
||||||
|
className="rounded-lg bg-emerald-500 px-3 py-2 text-xs font-semibold text-black disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busyId === item.id ? 'Saving…' : 'Approve'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Reject upload ${item.id}`}
|
||||||
|
disabled={busyId === item.id}
|
||||||
|
onClick={() => moderate(item, 'reject')}
|
||||||
|
className="rounded-lg bg-rose-500 px-3 py-2 text-xs font-semibold text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Moderation queue" className="mx-auto w-full max-w-5xl rounded-2xl border border-white/10 bg-slate-900/60 p-4 md:p-6">
|
<section aria-label="Moderation queue" className="mx-auto w-full max-w-6xl rounded-2xl border border-white/10 bg-slate-900/60 p-4 md:p-6">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-white">Pending Upload Moderation</h2>
|
<h2 className="text-lg font-semibold text-white">Pending Upload Moderation</h2>
|
||||||
<button type="button" onClick={loadPending} className="rounded-lg border border-white/20 px-3 py-1 text-xs text-white">
|
<button type="button" onClick={loadPending} className="rounded-lg border border-white/20 px-3 py-1 text-xs text-white">
|
||||||
@@ -48,47 +240,119 @@ export default function AdminUploadQueue() {
|
|||||||
{!loading && items.length === 0 ? <p role="status" className="text-sm text-white/60">No pending uploads.</p> : null}
|
{!loading && items.length === 0 ? <p role="status" className="text-sm text-white/60">No pending uploads.</p> : null}
|
||||||
|
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
<li key={item.id} aria-label={`Pending upload ${item.id}`} className="rounded-xl border border-white/10 bg-white/5 p-3">
|
const previewSrc = item.preview_url || item.preview_lg_url || null
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-white">{item.title || '(untitled upload)'}</div>
|
|
||||||
<div className="mt-1 text-xs text-white/65">{item.type} · {item.id}</div>
|
|
||||||
{item.preview_path ? <div className="mt-1 text-xs text-white/55">Preview: {item.preview_path}</div> : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full max-w-sm space-y-2">
|
return (
|
||||||
<input
|
<li key={`${item.queue}-${item.id}`} aria-label={`Pending upload ${item.id}`} className="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||||
type="text"
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
aria-label={`Moderation note for ${item.id}`}
|
<button
|
||||||
value={notes[item.id] || ''}
|
type="button"
|
||||||
onChange={(event) => setNotes((prev) => ({ ...prev, [item.id]: event.target.value }))}
|
aria-label={`Open details for ${item.title || item.id}`}
|
||||||
placeholder="Moderation note"
|
onClick={() => setSelected(item)}
|
||||||
className="w-full rounded-lg border border-white/15 bg-white/10 px-3 py-2 text-xs text-white"
|
className="flex min-w-0 flex-1 items-start gap-4 text-left"
|
||||||
/>
|
>
|
||||||
<div className="flex gap-2">
|
<PreviewImage
|
||||||
<button
|
src={previewSrc}
|
||||||
type="button"
|
alt={item.title || 'Artwork preview'}
|
||||||
aria-label={`Approve upload ${item.id}`}
|
className="h-40 w-40 shrink-0 rounded-lg object-cover"
|
||||||
onClick={() => moderate(item.id, 'approve')}
|
/>
|
||||||
className="rounded-lg bg-emerald-500 px-3 py-2 text-xs font-semibold text-black"
|
{renderMeta(item, { compact: true })}
|
||||||
>
|
</button>
|
||||||
Approve
|
|
||||||
</button>
|
{renderActions(item)}
|
||||||
<button
|
</div>
|
||||||
type="button"
|
</li>
|
||||||
aria-label={`Reject upload ${item.id}`}
|
)
|
||||||
onClick={() => moderate(item.id, 'reject')}
|
})}
|
||||||
className="rounded-lg bg-rose-500 px-3 py-2 text-xs font-semibold text-white"
|
</ul>
|
||||||
>
|
|
||||||
Reject
|
{selected ? (
|
||||||
</button>
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`Details for ${selected.title || selected.id}`}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="max-h-[92vh] w-full max-w-5xl overflow-y-auto rounded-2xl border border-white/10 bg-slate-950 p-5 shadow-2xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold text-white">{selected.title || '(untitled upload)'}</div>
|
||||||
|
<div className="mt-1 text-xs text-white/60">
|
||||||
|
{creatorName(selected)} · {selected.type} · {selected.id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close details"
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
className="rounded-lg border border-white/20 px-3 py-1 text-xs text-white"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
|
||||||
))}
|
<PreviewImage
|
||||||
</ul>
|
src={selected.preview_lg_url || selected.preview_url}
|
||||||
|
alt={selected.title || 'Artwork preview'}
|
||||||
|
className="mb-4 max-h-[62vh] w-full rounded-xl bg-black/40 object-contain"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||||
|
<div className="space-y-3 text-sm text-white/80">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Description</div>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap leading-6">{selected.description || 'No description'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Categories</div>
|
||||||
|
<p className="mt-1">{categoryList(selected).join(', ') || 'None'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Tags</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{tagList(selected).length > 0 ? tagList(selected).map((tag) => (
|
||||||
|
<span key={tag} className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[11px] text-white/70">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
)) : <span className="text-white/40">None</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 text-xs text-white/70 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Visibility</div>
|
||||||
|
<p className="mt-1">{selected.visibility || 'n/a'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Maturity</div>
|
||||||
|
<p className="mt-1">{isMature(selected) ? 'NSFW / mature' : (selected.maturity_status || 'safe')}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">License</div>
|
||||||
|
<p className="mt-1">{selected.license || 'n/a'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Submitted</div>
|
||||||
|
<p className="mt-1">{formatDate(selected.created_at) || 'n/a'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selected.moderation_note ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Review reasons</div>
|
||||||
|
<p className="mt-1">{selected.moderation_note}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderActions(selected)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,41 @@ function makePendingUpload(overrides = {}) {
|
|||||||
id: '11111111-1111-1111-1111-111111111111',
|
id: '11111111-1111-1111-1111-111111111111',
|
||||||
title: 'Neon Skyline',
|
title: 'Neon Skyline',
|
||||||
type: 'image',
|
type: 'image',
|
||||||
preview_path: 'tmp/drafts/1111/preview.webp',
|
description: 'A glowing city at dusk.',
|
||||||
|
preview_url: '/api/admin/uploads/11111111-1111-1111-1111-111111111111/preview',
|
||||||
|
preview_lg_url: '/api/admin/uploads/11111111-1111-1111-1111-111111111111/preview',
|
||||||
|
tags: ['neon', 'city'],
|
||||||
|
categories: ['Photography'],
|
||||||
|
user: { id: 7, name: 'Mira', username: 'mira' },
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makePendingArtwork(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 88,
|
||||||
|
title: 'Forest Spirit',
|
||||||
|
type: 'artwork',
|
||||||
|
description: 'A mossy guardian in fog.',
|
||||||
|
preview_url: 'https://files.skinbase.org/artworks/md/aa/bb/aabb.webp',
|
||||||
|
preview_lg_url: 'https://files.skinbase.org/artworks/lg/aa/bb/aabb.webp',
|
||||||
|
tags: ['forest', 'spirit'],
|
||||||
|
categories: ['Illustration'],
|
||||||
|
user: { id: 9, name: 'Kai', username: 'kai' },
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockQueue({ uploads = [], artworks = [] } = {}) {
|
||||||
|
window.axios.get.mockImplementation((url) => {
|
||||||
|
if (String(url).includes('/api/admin/artwork-review/pending')) {
|
||||||
|
return Promise.resolve({ data: { data: artworks } })
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({ data: { data: uploads } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe('AdminUploadQueue', () => {
|
describe('AdminUploadQueue', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.axios = {
|
window.axios = {
|
||||||
@@ -28,7 +58,7 @@ describe('AdminUploadQueue', () => {
|
|||||||
|
|
||||||
it('renders pending list with accessible controls', async () => {
|
it('renders pending list with accessible controls', async () => {
|
||||||
const upload = makePendingUpload()
|
const upload = makePendingUpload()
|
||||||
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
|
mockQueue({ uploads: [upload] })
|
||||||
|
|
||||||
render(<AdminUploadQueue />)
|
render(<AdminUploadQueue />)
|
||||||
|
|
||||||
@@ -36,6 +66,9 @@ describe('AdminUploadQueue', () => {
|
|||||||
|
|
||||||
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
|
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
|
||||||
expect(within(item).getByText('Neon Skyline')).not.toBeNull()
|
expect(within(item).getByText('Neon Skyline')).not.toBeNull()
|
||||||
|
expect(within(item).getByText('A glowing city at dusk.')).not.toBeNull()
|
||||||
|
expect(within(item).getByText('neon')).not.toBeNull()
|
||||||
|
expect(within(item).getByRole('img', { name: 'Neon Skyline' })).not.toBeNull()
|
||||||
expect(within(item).getByRole('textbox', { name: `Moderation note for ${upload.id}` })).not.toBeNull()
|
expect(within(item).getByRole('textbox', { name: `Moderation note for ${upload.id}` })).not.toBeNull()
|
||||||
expect(within(item).getByRole('button', { name: `Approve upload ${upload.id}` })).not.toBeNull()
|
expect(within(item).getByRole('button', { name: `Approve upload ${upload.id}` })).not.toBeNull()
|
||||||
expect(within(item).getByRole('button', { name: `Reject upload ${upload.id}` })).not.toBeNull()
|
expect(within(item).getByRole('button', { name: `Reject upload ${upload.id}` })).not.toBeNull()
|
||||||
@@ -43,7 +76,7 @@ describe('AdminUploadQueue', () => {
|
|||||||
|
|
||||||
it('approves upload and removes it from queue', async () => {
|
it('approves upload and removes it from queue', async () => {
|
||||||
const upload = makePendingUpload()
|
const upload = makePendingUpload()
|
||||||
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
|
mockQueue({ uploads: [upload] })
|
||||||
window.axios.post.mockResolvedValueOnce({ data: { success: true } })
|
window.axios.post.mockResolvedValueOnce({ data: { success: true } })
|
||||||
|
|
||||||
render(<AdminUploadQueue />)
|
render(<AdminUploadQueue />)
|
||||||
@@ -60,7 +93,7 @@ describe('AdminUploadQueue', () => {
|
|||||||
|
|
||||||
it('rejects upload with note and removes it from queue', async () => {
|
it('rejects upload with note and removes it from queue', async () => {
|
||||||
const upload = makePendingUpload({ id: '22222222-2222-2222-2222-222222222222', title: 'Retro Pack' })
|
const upload = makePendingUpload({ id: '22222222-2222-2222-2222-222222222222', title: 'Retro Pack' })
|
||||||
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
|
mockQueue({ uploads: [upload] })
|
||||||
window.axios.post.mockResolvedValueOnce({ data: { success: true } })
|
window.axios.post.mockResolvedValueOnce({ data: { success: true } })
|
||||||
|
|
||||||
render(<AdminUploadQueue />)
|
render(<AdminUploadQueue />)
|
||||||
@@ -85,7 +118,7 @@ describe('AdminUploadQueue', () => {
|
|||||||
|
|
||||||
it('shows API failure message and keeps item when moderation action fails', async () => {
|
it('shows API failure message and keeps item when moderation action fails', async () => {
|
||||||
const upload = makePendingUpload({ id: '33333333-3333-3333-3333-333333333333' })
|
const upload = makePendingUpload({ id: '33333333-3333-3333-3333-333333333333' })
|
||||||
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
|
mockQueue({ uploads: [upload] })
|
||||||
window.axios.post.mockRejectedValueOnce({
|
window.axios.post.mockRejectedValueOnce({
|
||||||
response: { data: { message: 'Moderation API failed.' } },
|
response: { data: { message: 'Moderation API failed.' } },
|
||||||
})
|
})
|
||||||
@@ -100,8 +133,49 @@ describe('AdminUploadQueue', () => {
|
|||||||
expect(screen.getByRole('listitem', { name: `Pending upload ${upload.id}` })).not.toBeNull()
|
expect(screen.getByRole('listitem', { name: `Pending upload ${upload.id}` })).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens a details popup with title, description, tags and preview', async () => {
|
||||||
|
const upload = makePendingUpload()
|
||||||
|
mockQueue({ uploads: [upload] })
|
||||||
|
|
||||||
|
render(<AdminUploadQueue />)
|
||||||
|
|
||||||
|
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
|
||||||
|
await userEvent.click(within(item).getByRole('button', { name: `Open details for ${upload.id}` }))
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Details for Neon Skyline' })
|
||||||
|
expect(within(dialog).getByText('A glowing city at dusk.')).not.toBeNull()
|
||||||
|
expect(within(dialog).getByText('Photography')).not.toBeNull()
|
||||||
|
expect(within(dialog).getByText('neon')).not.toBeNull()
|
||||||
|
expect(within(dialog).getByRole('img', { name: 'Neon Skyline' })).not.toBeNull()
|
||||||
|
expect(within(dialog).getByRole('button', { name: `Approve upload ${upload.id}` })).not.toBeNull()
|
||||||
|
expect(within(dialog).getByRole('button', { name: `Reject upload ${upload.id}` })).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('approves artwork review items from the details popup', async () => {
|
||||||
|
const artwork = makePendingArtwork()
|
||||||
|
mockQueue({ artworks: [artwork] })
|
||||||
|
window.axios.post.mockResolvedValueOnce({ data: { success: true } })
|
||||||
|
|
||||||
|
render(<AdminUploadQueue />)
|
||||||
|
|
||||||
|
const item = await screen.findByRole('listitem', { name: `Pending upload ${artwork.id}` })
|
||||||
|
expect(within(item).getByText('Forest Spirit')).not.toBeNull()
|
||||||
|
expect(within(item).getByText('forest')).not.toBeNull()
|
||||||
|
expect(within(item).getByRole('img', { name: 'Forest Spirit' })).not.toBeNull()
|
||||||
|
|
||||||
|
await userEvent.click(within(item).getByRole('button', { name: `Open details for ${artwork.id}` }))
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Details for Forest Spirit' })
|
||||||
|
await userEvent.click(within(dialog).getByRole('button', { name: `Approve upload ${artwork.id}` }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('listitem', { name: `Pending upload ${artwork.id}` })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(window.axios.post).toHaveBeenCalledWith(`/api/admin/artwork-review/${artwork.id}/approve`, { note: '' })
|
||||||
|
})
|
||||||
|
|
||||||
it('shows empty state when no pending uploads exist', async () => {
|
it('shows empty state when no pending uploads exist', async () => {
|
||||||
window.axios.get.mockResolvedValueOnce({ data: { data: [] } })
|
mockQueue()
|
||||||
|
|
||||||
render(<AdminUploadQueue />)
|
render(<AdminUploadQueue />)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\IndexArtworkJob;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\ContentType;
|
||||||
|
use App\Models\Notification;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ThumbnailService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('lists pending artworks with title, description, tags and preview', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$owner = User::factory()->create(['username' => 'mira']);
|
||||||
|
$hash = 'aabbccddeeff1122';
|
||||||
|
|
||||||
|
$artwork = Artwork::factory()->for($owner)->unapproved()->unpublished()->create([
|
||||||
|
'title' => 'Forest Spirit',
|
||||||
|
'description' => 'A mossy guardian in fog.',
|
||||||
|
'hash' => $hash,
|
||||||
|
'thumb_ext' => 'webp',
|
||||||
|
'artwork_status' => 'review',
|
||||||
|
'is_public' => false,
|
||||||
|
'moderation_note' => 'new_account, low_level',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$contentType = ContentType::query()->create([
|
||||||
|
'name' => 'Illustration',
|
||||||
|
'slug' => 'illustration-moderation-type',
|
||||||
|
'description' => '',
|
||||||
|
]);
|
||||||
|
$category = Category::query()->create([
|
||||||
|
'content_type_id' => $contentType->id,
|
||||||
|
'parent_id' => null,
|
||||||
|
'name' => 'Illustration',
|
||||||
|
'slug' => 'illustration-moderation',
|
||||||
|
'description' => null,
|
||||||
|
'image' => null,
|
||||||
|
'is_active' => true,
|
||||||
|
'sort_order' => 0,
|
||||||
|
]);
|
||||||
|
$artwork->categories()->attach($category->id);
|
||||||
|
|
||||||
|
$tag = Tag::query()->create(['name' => 'forest', 'slug' => 'forest', 'is_active' => true]);
|
||||||
|
$artwork->tags()->attach($tag->id, ['source' => 'user', 'confidence' => 1.0]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($admin)->getJson('/api/admin/artwork-review/pending');
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonPath('data.0.id', $artwork->id)
|
||||||
|
->assertJsonPath('data.0.title', 'Forest Spirit')
|
||||||
|
->assertJsonPath('data.0.description', 'A mossy guardian in fog.')
|
||||||
|
->assertJsonPath('data.0.tags.0', 'forest')
|
||||||
|
->assertJsonPath('data.0.categories.0', 'Illustration')
|
||||||
|
->assertJsonPath('data.0.user.username', 'mira')
|
||||||
|
->assertJsonPath('data.0.preview_url', ThumbnailService::fromHash($hash, 'webp', 'md'))
|
||||||
|
->assertJsonPath('data.0.preview_lg_url', ThumbnailService::fromHash($hash, 'webp', 'lg'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('approves a pending artwork', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$admin = User::factory()->create(['role' => 'moderator']);
|
||||||
|
$artwork = Artwork::factory()->unapproved()->unpublished()->create([
|
||||||
|
'artwork_status' => 'review',
|
||||||
|
'is_public' => false,
|
||||||
|
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($admin)->postJson("/api/admin/artwork-review/{$artwork->id}/approve", [
|
||||||
|
'note' => 'Looks good.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk()->assertJsonPath('status', 'published');
|
||||||
|
|
||||||
|
$artwork->refresh();
|
||||||
|
expect($artwork->artwork_status)->toBe('published');
|
||||||
|
expect($artwork->is_approved)->toBeTrue();
|
||||||
|
expect($artwork->is_public)->toBeTrue();
|
||||||
|
expect($artwork->moderation_note)->toBe('Looks good.');
|
||||||
|
expect((int) $artwork->moderated_by)->toBe((int) $admin->id);
|
||||||
|
|
||||||
|
$notice = Notification::query()
|
||||||
|
->where('user_id', $artwork->user_id)
|
||||||
|
->where('type', 'artwork_approved')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($notice)->not->toBeNull()
|
||||||
|
->and($notice->data['message'] ?? null)->toContain('was approved and is now live')
|
||||||
|
->and($notice->data['artwork_id'] ?? null)->toBe($artwork->id);
|
||||||
|
|
||||||
|
Queue::assertPushed(IndexArtworkJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a pending artwork', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$artwork = Artwork::factory()->unapproved()->unpublished()->create([
|
||||||
|
'artwork_status' => 'review',
|
||||||
|
'is_public' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($admin)->postJson("/api/admin/artwork-review/{$artwork->id}/reject", [
|
||||||
|
'note' => 'Low quality.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk()->assertJsonPath('status', 'rejected');
|
||||||
|
|
||||||
|
$artwork->refresh();
|
||||||
|
expect($artwork->artwork_status)->toBe('rejected');
|
||||||
|
expect($artwork->is_approved)->toBeFalse();
|
||||||
|
expect($artwork->is_public)->toBeFalse();
|
||||||
|
expect($artwork->moderation_note)->toBe('Low quality.');
|
||||||
|
|
||||||
|
$notice = Notification::query()
|
||||||
|
->where('user_id', $artwork->user_id)
|
||||||
|
->where('type', 'artwork_rejected')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($notice)->not->toBeNull()
|
||||||
|
->and($notice->data['message'] ?? null)->toContain('was not approved')
|
||||||
|
->and($notice->data['message'] ?? null)->toContain('Low quality.');
|
||||||
|
|
||||||
|
Queue::assertPushed(IndexArtworkJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies artwork review access to regular users', function () {
|
||||||
|
$user = User::factory()->create(['role' => 'user']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->getJson('/api/admin/artwork-review/pending')
|
||||||
|
->assertStatus(403);
|
||||||
|
});
|
||||||
@@ -76,13 +76,41 @@ it('admin sees pending uploads', function () {
|
|||||||
$owner = User::factory()->create();
|
$owner = User::factory()->create();
|
||||||
$categoryId = createModerationCategory();
|
$categoryId = createModerationCategory();
|
||||||
|
|
||||||
createModerationDraft($owner->id, $categoryId, ['title' => 'First Pending']);
|
createModerationDraft($owner->id, $categoryId, [
|
||||||
|
'title' => 'First Pending',
|
||||||
|
'description' => 'A spring garden scene.',
|
||||||
|
'tags' => json_encode(['tulip', 'garden']),
|
||||||
|
]);
|
||||||
createModerationDraft($owner->id, $categoryId, ['title' => 'Second Pending']);
|
createModerationDraft($owner->id, $categoryId, ['title' => 'Second Pending']);
|
||||||
|
|
||||||
$response = $this->actingAs($admin)->getJson('/api/admin/uploads/pending');
|
$response = $this->actingAs($admin)->getJson('/api/admin/uploads/pending');
|
||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
$response->assertJsonCount(2, 'data');
|
$response->assertJsonCount(2, 'data');
|
||||||
|
$response->assertJsonPath('data.0.title', 'First Pending');
|
||||||
|
$response->assertJsonPath('data.0.description', 'A spring garden scene.');
|
||||||
|
$response->assertJsonPath('data.0.tags.0', 'tulip');
|
||||||
|
expect($response->json('data.0.preview_url'))->toContain('/api/admin/uploads/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streams a signed preview for a pending upload', function () {
|
||||||
|
Storage::fake('local');
|
||||||
|
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$categoryId = createModerationCategory();
|
||||||
|
$uploadId = createModerationDraft($owner->id, $categoryId);
|
||||||
|
Storage::disk('local')->put("tmp/drafts/{$uploadId}/preview.webp", 'preview-bytes');
|
||||||
|
|
||||||
|
$pending = $this->actingAs($admin)->getJson('/api/admin/uploads/pending');
|
||||||
|
$pending->assertOk();
|
||||||
|
$previewUrl = (string) $pending->json('data.0.preview_url');
|
||||||
|
|
||||||
|
$response = $this->actingAs($admin)->get($previewUrl);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
expect($response->headers->get('content-type'))->toStartWith('image/webp');
|
||||||
|
expect($response->streamedContent())->toBe('preview-bytes');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('non-admin is denied moderation API access', function () {
|
it('non-admin is denied moderation API access', function () {
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Moderation\ArtworkUploadPolicy;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
function policyPendingArtwork(User $user, array $overrides = []): Artwork
|
||||||
|
{
|
||||||
|
return Artwork::factory()->for($user)->unapproved()->unpublished()->create(array_merge([
|
||||||
|
'artwork_status' => 'draft',
|
||||||
|
'title' => 'Fresh upload',
|
||||||
|
], $overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
function policyApprovedArtworks(User $user, int $count, array $overrides = []): void
|
||||||
|
{
|
||||||
|
Artwork::factory()->for($user)->count($count)->create(array_merge([
|
||||||
|
'is_approved' => true,
|
||||||
|
'is_public' => true,
|
||||||
|
'artwork_status' => 'published',
|
||||||
|
'published_at' => now()->subDay(),
|
||||||
|
'approval_source' => null,
|
||||||
|
], $overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sends new users without five approved artworks to review', function () {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'role' => 'user',
|
||||||
|
'created_at' => now()->subYear(),
|
||||||
|
'level' => 8,
|
||||||
|
]);
|
||||||
|
policyApprovedArtworks($user, 4);
|
||||||
|
$pending = policyPendingArtwork($user);
|
||||||
|
|
||||||
|
$policy = app(ArtworkUploadPolicy::class)->assess($user, $pending);
|
||||||
|
|
||||||
|
expect($policy['requires_review'])->toBeTrue()
|
||||||
|
->and($policy['approved_uploads'])->toBe(4)
|
||||||
|
->and($policy['reasons'])->toContain('insufficient_approved_uploads');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-publishes established creators with five approved artworks even without moderator source', function () {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'role' => 'user',
|
||||||
|
'created_at' => now()->subYear(),
|
||||||
|
'level' => 1,
|
||||||
|
]);
|
||||||
|
policyApprovedArtworks($user, 5);
|
||||||
|
$pending = policyPendingArtwork($user);
|
||||||
|
|
||||||
|
$policy = app(ArtworkUploadPolicy::class)->assess($user, $pending);
|
||||||
|
|
||||||
|
expect($policy['requires_review'])->toBeFalse()
|
||||||
|
->and($policy['approved_uploads'])->toBe(5)
|
||||||
|
->and($policy['reasons'])->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not auto-publish the sixth upload when a new user only has five queued or auto-trusted works', function () {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'role' => 'user',
|
||||||
|
'created_at' => now()->subYear(),
|
||||||
|
'level' => 8,
|
||||||
|
]);
|
||||||
|
Artwork::factory()->for($user)->count(5)->unapproved()->unpublished()->create([
|
||||||
|
'artwork_status' => 'review',
|
||||||
|
'is_public' => false,
|
||||||
|
'approval_source' => null,
|
||||||
|
]);
|
||||||
|
policyApprovedArtworks($user, 5, ['approval_source' => 'trusted_auto']);
|
||||||
|
$sixth = policyPendingArtwork($user);
|
||||||
|
|
||||||
|
$policy = app(ArtworkUploadPolicy::class)->assess($user, $sixth);
|
||||||
|
|
||||||
|
expect($policy['requires_review'])->toBeTrue()
|
||||||
|
->and($policy['approved_uploads'])->toBe(0)
|
||||||
|
->and($policy['reasons'])->toContain('insufficient_approved_uploads');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-publishes only after five moderator-approved or legacy published artworks', function () {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'role' => 'user',
|
||||||
|
'created_at' => now()->subYear(),
|
||||||
|
'level' => 4,
|
||||||
|
]);
|
||||||
|
policyApprovedArtworks($user, 3, ['approval_source' => 'trusted_auto']);
|
||||||
|
policyApprovedArtworks($user, 5, ['approval_source' => 'moderator']);
|
||||||
|
$pending = policyPendingArtwork($user);
|
||||||
|
|
||||||
|
$policy = app(ArtworkUploadPolicy::class)->assess($user, $pending);
|
||||||
|
|
||||||
|
expect($policy['requires_review'])->toBeFalse()
|
||||||
|
->and($policy['approved_uploads'])->toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not count the artwork currently being published', function () {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'role' => 'user',
|
||||||
|
'created_at' => now()->subYear(),
|
||||||
|
'level' => 4,
|
||||||
|
]);
|
||||||
|
policyApprovedArtworks($user, 4);
|
||||||
|
$pending = policyPendingArtwork($user, [
|
||||||
|
'is_approved' => true,
|
||||||
|
'artwork_status' => 'published',
|
||||||
|
'published_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = app(ArtworkUploadPolicy::class)->assess($user, $pending);
|
||||||
|
|
||||||
|
expect($policy['approved_uploads'])->toBe(4)
|
||||||
|
->and($policy['requires_review'])->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still reviews promotional uploads from trusted creators', function () {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'role' => 'user',
|
||||||
|
'created_at' => now()->subYear(),
|
||||||
|
'level' => 6,
|
||||||
|
]);
|
||||||
|
policyApprovedArtworks($user, 5);
|
||||||
|
$pending = policyPendingArtwork($user, [
|
||||||
|
'title' => 'Buy now cheap backlinks',
|
||||||
|
'description' => 'Visit https://spam.example.com for a promo code',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = app(ArtworkUploadPolicy::class)->assess($user, $pending);
|
||||||
|
|
||||||
|
expect($policy['requires_review'])->toBeTrue()
|
||||||
|
->and($policy['reasons'])->toContain('promotional_content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets admins publish without review', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$pending = policyPendingArtwork($admin);
|
||||||
|
|
||||||
|
$policy = app(ArtworkUploadPolicy::class)->assess($admin, $pending);
|
||||||
|
|
||||||
|
expect($policy['requires_review'])->toBeFalse()
|
||||||
|
->and($policy['reasons'])->toBe([]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user