Wire admin studio SSR and search infrastructure
This commit is contained in:
@@ -158,6 +158,7 @@ final class StudioArtworksApiController extends Controller
|
||||
'world_submissions' => 'sometimes|array|max:12',
|
||||
'world_submissions.*.world_id' => 'required|integer|exists:worlds,id',
|
||||
'world_submissions.*.note' => 'nullable|string|max:1000',
|
||||
'world_submissions.*.source_surface' => 'nullable|string|max:80',
|
||||
'evolution_target_artwork_id' => 'sometimes|nullable|integer|min:1',
|
||||
'evolution_relation_type' => 'sometimes|nullable|string|in:remake_of,remaster_of,revision_of,inspired_by,variation_of',
|
||||
'evolution_note' => 'sometimes|nullable|string|max:1200',
|
||||
@@ -284,16 +285,8 @@ final class StudioArtworksApiController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Reindex in Meilisearch
|
||||
try {
|
||||
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) {
|
||||
$artwork->searchable();
|
||||
} else {
|
||||
$artwork->unsearchable();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Meilisearch may be unavailable
|
||||
}
|
||||
// Reindex in Meilisearch — dispatches IndexArtworkJob which writes directly, no Scout hop.
|
||||
$this->searchIndexer->update($artwork);
|
||||
|
||||
// Reload relationships for response
|
||||
$artwork->load(['categories.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']);
|
||||
|
||||
@@ -124,6 +124,23 @@ final class StudioController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function uploadQueue(Request $request): Response
|
||||
{
|
||||
$queue = app(\App\Services\Uploads\UploadQueueService::class)->listPayload(
|
||||
$request->user(),
|
||||
$request->only(['batch_id', 'status', 'sort'])
|
||||
);
|
||||
|
||||
return Inertia::render('Studio/StudioUploadQueue', [
|
||||
'title' => 'Upload Queue',
|
||||
'description' => 'Upload multiple artworks, track processing, and publish only when each draft is ready.',
|
||||
'queue' => $queue,
|
||||
'contentTypes' => $this->getCategories(),
|
||||
'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880),
|
||||
'chunkRequestTimeoutMs' => (int) config('uploads.chunk.request_timeout_ms', 45000),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archived (/studio/archived)
|
||||
*/
|
||||
|
||||
231
app/Http/Controllers/Studio/StudioNewsMediaApiController.php
Normal file
231
app/Http/Controllers/Studio/StudioNewsMediaApiController.php
Normal file
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
|
||||
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
final class StudioNewsMediaApiController extends Controller
|
||||
{
|
||||
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
private const MAX_FILE_SIZE_KB = 6144;
|
||||
|
||||
private const MAX_WIDTH = 2200;
|
||||
|
||||
private const MAX_HEIGHT = 1400;
|
||||
|
||||
private const MIN_WIDTH = 1200;
|
||||
|
||||
private const MIN_HEIGHT = 630;
|
||||
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
try {
|
||||
$this->manager = extension_loaded('gd')
|
||||
? new ImageManager(new GdDriver())
|
||||
: new ImageManager(new ImagickDriver());
|
||||
} catch (\Throwable) {
|
||||
$this->manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'image' => [
|
||||
'required',
|
||||
'file',
|
||||
'image',
|
||||
'max:' . self::MAX_FILE_SIZE_KB,
|
||||
'mimes:jpg,jpeg,png,webp',
|
||||
'mimetypes:image/jpeg,image/png,image/webp',
|
||||
],
|
||||
]);
|
||||
|
||||
/** @var UploadedFile $file */
|
||||
$file = $validated['image'];
|
||||
|
||||
try {
|
||||
$stored = $this->storeMediaFile($file);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'path' => $stored['path'],
|
||||
'url' => $this->publicUrlForPath($stored['path']),
|
||||
'width' => $stored['width'],
|
||||
'height' => $stored['height'],
|
||||
'mime_type' => 'image/webp',
|
||||
'size_bytes' => $stored['size_bytes'],
|
||||
]);
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json([
|
||||
'error' => 'Validation failed',
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
} catch (\Throwable $e) {
|
||||
logger()->error('News media upload failed', [
|
||||
'user_id' => (int) ($request->user()?->id ?? 0),
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Upload failed',
|
||||
'message' => 'Could not upload image right now.',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'path' => ['required', 'string', 'max:2048'],
|
||||
]);
|
||||
|
||||
$this->deleteMediaFile((string) $validated['path']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{path:string,width:int,height:int,size_bytes:int}
|
||||
*/
|
||||
private function storeMediaFile(UploadedFile $file): array
|
||||
{
|
||||
$this->assertImageManager();
|
||||
$this->assertStorageIsAllowed();
|
||||
|
||||
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
|
||||
|
||||
if ($uploadPath === '' || ! is_readable($uploadPath)) {
|
||||
throw new RuntimeException('Unable to resolve uploaded image path.');
|
||||
}
|
||||
|
||||
$raw = file_get_contents($uploadPath);
|
||||
if ($raw === false || $raw === '') {
|
||||
throw new RuntimeException('Unable to read uploaded image.');
|
||||
}
|
||||
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = strtolower((string) $finfo->buffer($raw));
|
||||
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
|
||||
throw new RuntimeException('Unsupported image mime type.');
|
||||
}
|
||||
|
||||
$size = @getimagesizefromstring($raw);
|
||||
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
|
||||
throw new RuntimeException('Uploaded file is not a valid image.');
|
||||
}
|
||||
|
||||
$width = (int) ($size[0] ?? 0);
|
||||
$height = (int) ($size[1] ?? 0);
|
||||
|
||||
if ($width < self::MIN_WIDTH || $height < self::MIN_HEIGHT) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Image is too small. Minimum required size is %dx%d.',
|
||||
self::MIN_WIDTH,
|
||||
self::MIN_HEIGHT,
|
||||
));
|
||||
}
|
||||
|
||||
$image = $this->manager->read($raw)->scaleDown(width: self::MAX_WIDTH, height: self::MAX_HEIGHT);
|
||||
$encoded = (string) $image->encode(new WebpEncoder(85));
|
||||
|
||||
$hash = hash('sha256', $encoded);
|
||||
$path = $this->mediaPath($hash);
|
||||
$disk = Storage::disk($this->mediaDiskName());
|
||||
|
||||
$written = $disk->put($path, $encoded, [
|
||||
'visibility' => 'public',
|
||||
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||
'ContentType' => 'image/webp',
|
||||
]);
|
||||
|
||||
if ($written !== true) {
|
||||
throw new RuntimeException('Unable to store image in object storage.');
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $path,
|
||||
'width' => (int) $image->width(),
|
||||
'height' => (int) $image->height(),
|
||||
'size_bytes' => strlen($encoded),
|
||||
];
|
||||
}
|
||||
|
||||
private function authorizeNews(Request $request): void
|
||||
{
|
||||
abort_unless($request->user() && ($request->user()->isAdmin() || $request->user()->isModerator()), 403);
|
||||
}
|
||||
|
||||
private function mediaDiskName(): string
|
||||
{
|
||||
return (string) config('uploads.object_storage.disk', 's3');
|
||||
}
|
||||
|
||||
private function mediaPath(string $hash): string
|
||||
{
|
||||
return sprintf(
|
||||
'news/covers/%s/%s/%s.webp',
|
||||
substr($hash, 0, 2),
|
||||
substr($hash, 2, 2),
|
||||
$hash,
|
||||
);
|
||||
}
|
||||
|
||||
private function publicUrlForPath(string $path): string
|
||||
{
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function deleteMediaFile(string $path): void
|
||||
{
|
||||
$trimmed = ltrim(trim($path), '/');
|
||||
|
||||
if ($trimmed === '' || ! Str::startsWith($trimmed, 'news/covers/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk($this->mediaDiskName())->delete($trimmed);
|
||||
}
|
||||
|
||||
private function assertImageManager(): void
|
||||
{
|
||||
if ($this->manager !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Image processing is not available on this environment.');
|
||||
}
|
||||
|
||||
private function assertStorageIsAllowed(): void
|
||||
{
|
||||
if (! app()->environment('production')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$diskName = $this->mediaDiskName();
|
||||
if (in_array($diskName, ['local', 'public'], true)) {
|
||||
throw new RuntimeException('Production news media storage must use object storage, not local/public disks.');
|
||||
}
|
||||
}
|
||||
}
|
||||
113
app/Http/Controllers/Studio/StudioUploadQueueApiController.php
Normal file
113
app/Http/Controllers/Studio/StudioUploadQueueApiController.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Uploads\UploadQueueService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
final class StudioUploadQueueApiController extends Controller
|
||||
{
|
||||
public function index(Request $request, UploadQueueService $queue): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$queue->listPayload($request->user(), $request->only(['batch_id', 'status', 'sort']))
|
||||
);
|
||||
}
|
||||
|
||||
public function store(Request $request, UploadQueueService $queue): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['nullable', 'string', 'max:160'],
|
||||
'files' => ['required', 'array', 'min:1', 'max:50'],
|
||||
'files.*.name' => ['required', 'string', 'max:255'],
|
||||
'defaults' => ['nullable', 'array'],
|
||||
'defaults.category_id' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'defaults.tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||
'defaults.tags.*' => ['string', 'max:64'],
|
||||
'defaults.visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
|
||||
'defaults.is_mature' => ['nullable', 'boolean'],
|
||||
'defaults.group' => ['nullable', 'string', 'max:90'],
|
||||
]);
|
||||
|
||||
$batch = $queue->createBatch(
|
||||
$request->user(),
|
||||
(array) $validated['files'],
|
||||
(array) ($validated['defaults'] ?? []),
|
||||
Arr::get($validated, 'name')
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'batch' => [
|
||||
'id' => (int) $batch->id,
|
||||
'name' => $batch->name,
|
||||
],
|
||||
'items' => $batch->items->map(fn ($item): array => [
|
||||
'id' => (int) $item->id,
|
||||
'artwork_id' => (int) $item->artwork_id,
|
||||
'original_filename' => (string) $item->original_filename,
|
||||
])->values()->all(),
|
||||
'queue' => $queue->listPayload($request->user(), ['batch_id' => (int) $batch->id]),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function markFailed(Request $request, int $id, UploadQueueService $queue): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'error_code' => ['nullable', 'string', 'max:64'],
|
||||
'error_message' => ['nullable', 'string', 'max:4000'],
|
||||
]);
|
||||
|
||||
$queue->markItemFailedForUser(
|
||||
$request->user(),
|
||||
$id,
|
||||
(string) ($validated['error_code'] ?? 'upload_failed'),
|
||||
(string) ($validated['error_message'] ?? 'Upload failed before processing completed.')
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function bulk(Request $request, UploadQueueService $queue): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'action' => ['required', 'string', 'in:publish,delete,apply_category,apply_tags,set_visibility,generate_ai'],
|
||||
'item_ids' => ['required', 'array', 'min:1', 'max:200'],
|
||||
'item_ids.*' => ['integer'],
|
||||
'params' => ['nullable', 'array'],
|
||||
'params.category_id' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'params.tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||
'params.tags.*' => ['string', 'max:64'],
|
||||
'params.visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
|
||||
'confirm' => ['required_if:action,delete', 'string'],
|
||||
]);
|
||||
|
||||
if (($validated['action'] ?? '') === 'delete' && ($validated['confirm'] ?? '') !== 'DELETE') {
|
||||
return response()->json([
|
||||
'errors' => ['You must type DELETE to confirm draft deletion.'],
|
||||
'success' => 0,
|
||||
'failed' => count((array) ($validated['item_ids'] ?? [])),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$result = $queue->bulkAction(
|
||||
$request->user(),
|
||||
(string) $validated['action'],
|
||||
(array) $validated['item_ids'],
|
||||
(array) ($validated['params'] ?? [])
|
||||
);
|
||||
|
||||
return response()->json($result, $result['success'] > 0 ? 200 : 422);
|
||||
}
|
||||
|
||||
public function retry(Request $request, int $id, UploadQueueService $queue): JsonResponse
|
||||
{
|
||||
$queue->retryProcessingForUser($request->user(), $id);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user