diff --git a/app/Http/Controllers/Api/Admin/ArtworkModerationController.php b/app/Http/Controllers/Api/Admin/ArtworkModerationController.php new file mode 100644 index 00000000..9285b528 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ArtworkModerationController.php @@ -0,0 +1,130 @@ +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(), + ]; + } +} diff --git a/app/Http/Controllers/Api/Admin/UploadModerationController.php b/app/Http/Controllers/Api/Admin/UploadModerationController.php index e62aa453..64068ce9 100644 --- a/app/Http/Controllers/Api/Admin/UploadModerationController.php +++ b/app/Http/Controllers/Api/Admin/UploadModerationController.php @@ -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; + } } diff --git a/app/Notifications/NewArtworkReviewNotification.php b/app/Notifications/NewArtworkReviewNotification.php new file mode 100644 index 00000000..c375789b --- /dev/null +++ b/app/Notifications/NewArtworkReviewNotification.php @@ -0,0 +1,39 @@ +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.'); + } +} diff --git a/app/Services/Moderation/ArtworkUploadPolicy.php b/app/Services/Moderation/ArtworkUploadPolicy.php new file mode 100644 index 00000000..33a5eda8 --- /dev/null +++ b/app/Services/Moderation/ArtworkUploadPolicy.php @@ -0,0 +1,97 @@ +, 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); + } +} diff --git a/app/Services/Moderation/ContentModerationPersistenceService.php b/app/Services/Moderation/ContentModerationPersistenceService.php index 64e125c6..a9901de3 100644 --- a/app/Services/Moderation/ContentModerationPersistenceService.php +++ b/app/Services/Moderation/ContentModerationPersistenceService.php @@ -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 $context + * @param array $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 $context + * @param array $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; } -} \ No newline at end of file +} diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index d249fbff..f53330d4 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -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) { diff --git a/app/Services/Uploads/UploadQueueService.php b/app/Services/Uploads/UploadQueueService.php index 8590cba0..a6a30cd3 100644 --- a/app/Services/Uploads/UploadQueueService.php +++ b/app/Services/Uploads/UploadQueueService.php @@ -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, ''); } -} \ No newline at end of file +} diff --git a/config/uploads.php b/config/uploads.php index a6905236..89ee41b5 100644 --- a/config/uploads.php +++ b/config/uploads.php @@ -9,6 +9,10 @@ return [ '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' => [ 'disk' => env('ARTWORKS_OBJECT_DISK', 's3'), 'prefix' => env('ARTWORKS_OBJECT_PREFIX', 'artworks'), @@ -154,4 +158,16 @@ return [ 'enabled' => env('UPLOAD_SCAN_ENABLED', false), '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', + ], + ], ]; diff --git a/database/migrations/2026_09_10_000001_add_artwork_moderation_audit_fields.php b/database/migrations/2026_09_10_000001_add_artwork_moderation_audit_fields.php new file mode 100644 index 00000000..e4b41970 --- /dev/null +++ b/database/migrations/2026_09_10_000001_add_artwork_moderation_audit_fields.php @@ -0,0 +1,28 @@ +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']); + }); + } +}; diff --git a/resources/js/components/admin/AdminUploadQueue.jsx b/resources/js/components/admin/AdminUploadQueue.jsx index 245160d5..c6f2bead 100644 --- a/resources/js/components/admin/AdminUploadQueue.jsx +++ b/resources/js/components/admin/AdminUploadQueue.jsx @@ -1,18 +1,98 @@ 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 ( +
+ No preview +
+ ) + } + + return ( + {alt} 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 ( + + {children} + + ) +} + export default function AdminUploadQueue() { const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [notes, setNotes] = useState({}) + const [selected, setSelected] = useState(null) + const [busyId, setBusyId] = useState(null) const loadPending = async () => { setLoading(true) setError('') try { - const response = await window.axios.get('/api/admin/uploads/pending') - setItems(Array.isArray(response?.data?.data) ? response.data.data : []) + const [legacyResponse, artworkResponse] = await Promise.all([ + 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) { setError(loadError?.response?.data?.message || 'Failed to load moderation queue.') } finally { @@ -24,18 +104,130 @@ export default function AdminUploadQueue() { 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 { const payload = { note: String(notes[id] || '') } - await window.axios.post(`/api/admin/uploads/${id}/${action}`, payload) - setItems((prev) => prev.filter((item) => item.id !== id)) + const base = item.queue === 'artwork' ? '/api/admin/artwork-review' : '/api/admin/uploads' + 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) { 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 ( +
+
+
{item.title || '(untitled upload)'}
+ + {item.queue === 'artwork' ? 'Artwork' : 'Draft upload'} + + {isMature(item) ? NSFW : null} +
+
+ {creatorName(item)} · {item.type} · {item.id} +
+ {createdAt ?
{createdAt}
: null} + {item.description ? ( +

+ {item.description} +

+ ) : ( +

No description

+ )} + {categories.length > 0 ? ( +
{categories.join(' · ')}
+ ) : null} + {tags.length > 0 ? ( +
+ {(compact ? tags.slice(0, 10) : tags).map((tag) => ( + + {tag} + + ))} +
+ ) : ( +
No tags
+ )} + {item.moderation_note ? ( +
Review reasons: {item.moderation_note}
+ ) : null} +
+ ) + } + + const renderActions = (item) => ( +
+ 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" + /> +
+ + + +
+
+ ) + return ( -
+

Pending Upload Moderation

- + return ( +
  • +
    + + + {renderActions(item)} +
    +
  • + ) + })} + + + {selected ? ( +
    setSelected(null)} + > +
    event.stopPropagation()} + > +
    +
    +
    {selected.title || '(untitled upload)'}
    +
    + {creatorName(selected)} · {selected.type} · {selected.id}
    +
    - - ))} - + + + +
    +
    +
    +
    Description
    +

    {selected.description || 'No description'}

    +
    +
    +
    Categories
    +

    {categoryList(selected).join(', ') || 'None'}

    +
    +
    +
    Tags
    +
    + {tagList(selected).length > 0 ? tagList(selected).map((tag) => ( + + {tag} + + )) : None} +
    +
    +
    +
    +
    Visibility
    +

    {selected.visibility || 'n/a'}

    +
    +
    +
    Maturity
    +

    {isMature(selected) ? 'NSFW / mature' : (selected.maturity_status || 'safe')}

    +
    +
    +
    License
    +

    {selected.license || 'n/a'}

    +
    +
    +
    Submitted
    +

    {formatDate(selected.created_at) || 'n/a'}

    +
    +
    + {selected.moderation_note ? ( +
    +
    Review reasons
    +

    {selected.moderation_note}

    +
    + ) : null} +
    + + {renderActions(selected)} +
    +
    +
    + ) : null}
    ) } diff --git a/resources/js/components/admin/AdminUploadQueue.test.jsx b/resources/js/components/admin/AdminUploadQueue.test.jsx index 352535c1..2c5e9be4 100644 --- a/resources/js/components/admin/AdminUploadQueue.test.jsx +++ b/resources/js/components/admin/AdminUploadQueue.test.jsx @@ -9,11 +9,41 @@ function makePendingUpload(overrides = {}) { id: '11111111-1111-1111-1111-111111111111', title: 'Neon Skyline', 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, } } +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', () => { beforeEach(() => { window.axios = { @@ -28,7 +58,7 @@ describe('AdminUploadQueue', () => { it('renders pending list with accessible controls', async () => { const upload = makePendingUpload() - window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } }) + mockQueue({ uploads: [upload] }) render() @@ -36,6 +66,9 @@ describe('AdminUploadQueue', () => { const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` }) 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('button', { name: `Approve 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 () => { const upload = makePendingUpload() - window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } }) + mockQueue({ uploads: [upload] }) window.axios.post.mockResolvedValueOnce({ data: { success: true } }) render() @@ -60,7 +93,7 @@ describe('AdminUploadQueue', () => { it('rejects upload with note and removes it from queue', async () => { 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 } }) render() @@ -85,7 +118,7 @@ describe('AdminUploadQueue', () => { it('shows API failure message and keeps item when moderation action fails', async () => { const upload = makePendingUpload({ id: '33333333-3333-3333-3333-333333333333' }) - window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } }) + mockQueue({ uploads: [upload] }) window.axios.post.mockRejectedValueOnce({ response: { data: { message: 'Moderation API failed.' } }, }) @@ -100,8 +133,49 @@ describe('AdminUploadQueue', () => { 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() + + 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() + + 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 () => { - window.axios.get.mockResolvedValueOnce({ data: { data: [] } }) + mockQueue() render() diff --git a/tests/Feature/Admin/ArtworkModerationTest.php b/tests/Feature/Admin/ArtworkModerationTest.php new file mode 100644 index 00000000..f0e580dd --- /dev/null +++ b/tests/Feature/Admin/ArtworkModerationTest.php @@ -0,0 +1,138 @@ +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); +}); diff --git a/tests/Feature/Admin/UploadModerationTest.php b/tests/Feature/Admin/UploadModerationTest.php index 7b50779c..7ffc78f5 100644 --- a/tests/Feature/Admin/UploadModerationTest.php +++ b/tests/Feature/Admin/UploadModerationTest.php @@ -76,13 +76,41 @@ it('admin sees pending uploads', function () { $owner = User::factory()->create(); $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']); $response = $this->actingAs($admin)->getJson('/api/admin/uploads/pending'); $response->assertOk(); $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 () { diff --git a/tests/Unit/Moderation/ArtworkUploadPolicyTest.php b/tests/Unit/Moderation/ArtworkUploadPolicyTest.php new file mode 100644 index 00000000..61b914d5 --- /dev/null +++ b/tests/Unit/Moderation/ArtworkUploadPolicyTest.php @@ -0,0 +1,144 @@ +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([]); +});