Require review for untrusted accounts, add admin artwork review APIs, and keep queued or auto-trusted publishes from counting as established history.
154 lines
5.3 KiB
PHP
154 lines
5.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api\Admin;
|
|
|
|
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()
|
|
->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);
|
|
|
|
if (! $upload) {
|
|
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
|
|
}
|
|
|
|
$upload->moderation_status = 'approved';
|
|
$upload->moderated_at = now();
|
|
$upload->moderated_by = (int) $request->user()->id;
|
|
$upload->moderation_note = $request->input('note');
|
|
$upload->save();
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'id' => (string) $upload->id,
|
|
'moderation_status' => (string) $upload->moderation_status,
|
|
], Response::HTTP_OK);
|
|
}
|
|
|
|
public function reject(string $id, Request $request): JsonResponse
|
|
{
|
|
$upload = Upload::query()->find($id);
|
|
|
|
if (! $upload) {
|
|
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
|
|
}
|
|
|
|
$upload->moderation_status = 'rejected';
|
|
$upload->status = 'rejected';
|
|
$upload->processing_state = 'rejected';
|
|
$upload->moderated_at = now();
|
|
$upload->moderated_by = (int) $request->user()->id;
|
|
$upload->moderation_note = (string) $request->input('note', '');
|
|
$upload->save();
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'id' => (string) $upload->id,
|
|
'status' => (string) $upload->status,
|
|
'processing_state' => (string) $upload->processing_state,
|
|
'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;
|
|
}
|
|
}
|