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 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;
}
}