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:
test
2026-09-20 14:49:06 +02:00
parent 5437f84c4c
commit 366dbf1de8
14 changed files with 1175 additions and 70 deletions
@@ -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;
} }
+54
View File
@@ -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) {
+19 -1
View File
@@ -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);
} }
+16
View File
@@ -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);
});
+29 -1
View File
@@ -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([]);
});