Files
SkinbaseNova/app/Http/Controllers/Studio/StudioArtworksApiController.php
2026-04-18 17:02:56 +02:00

1082 lines
49 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\ArtworkVersion;
use App\Services\ArtworkEvolutionService;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\ArtworkSearchIndexer;
use App\Services\ArtworkAttributionService;
use App\Services\Artworks\ArtworkPublicationService;
use App\Services\TagService;
use App\Services\ArtworkVersioningService;
use App\Services\Studio\StudioArtworkQueryService;
use App\Services\Studio\StudioBulkActionService;
use App\Services\Tags\TagDiscoveryService;
use App\Services\Worlds\WorldSubmissionService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
/**
* JSON API endpoints for the Studio artwork manager.
*/
final class StudioArtworksApiController extends Controller
{
public function __construct(
private readonly StudioArtworkQueryService $queryService,
private readonly StudioBulkActionService $bulkService,
private readonly ArtworkVersioningService $versioningService,
private readonly ArtworkSearchIndexer $searchIndexer,
private readonly TagDiscoveryService $tagDiscoveryService,
private readonly TagService $tagService,
private readonly ArtworkCdnPurgeService $cdnPurge,
private readonly ArtworkPublicationService $artworkPublication,
) {}
/**
* GET /api/studio/artworks
* List artworks with search, filter, sort, pagination.
*/
public function index(Request $request): JsonResponse
{
$userId = $request->user()->id;
$this->artworkPublication->publishDueScheduledForUser((int) $userId);
$filters = $request->only([
'q', 'status', 'category', 'tags', 'date_from', 'date_to',
'performance', 'sort',
]);
$perPage = (int) $request->get('per_page', 24);
$perPage = min(max($perPage, 12), 100);
$paginator = $this->queryService->list($userId, $filters, $perPage);
// Transform the paginator items to a clean DTO
$items = collect($paginator->items())->map(fn ($artwork) => $this->transformArtwork($artwork));
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
]);
}
/**
* POST /api/studio/artworks/bulk
* Execute bulk operations.
*/
public function bulk(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'action' => 'required|string|in:publish,unpublish,archive,unarchive,delete,change_category,add_tags,remove_tags',
'artwork_ids' => 'required|array|min:1|max:200',
'artwork_ids.*' => 'integer',
'params' => 'sometimes|array',
'params.category_id' => 'sometimes|integer|exists:categories,id',
'params.tag_ids' => 'sometimes|array',
'params.tag_ids.*' => 'integer|exists:tags,id',
'confirm' => 'required_if:action,delete|string',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$data = $validator->validated();
// Require explicit DELETE confirmation
if ($data['action'] === 'delete' && ($data['confirm'] ?? '') !== 'DELETE') {
return response()->json([
'errors' => ['confirm' => ['You must type DELETE to confirm permanent deletion.']],
], 422);
}
$result = $this->bulkService->execute(
$request->user()->id,
$data['action'],
$data['artwork_ids'],
$data['params'] ?? [],
);
$statusCode = $result['failed'] > 0 && $result['success'] === 0 ? 422 : 200;
return response()->json($result, $statusCode);
}
/**
* PUT /api/studio/artworks/{id}
* Update artwork details (title, description, visibility).
*/
public function update(Request $request, int $id, ArtworkAttributionService $attribution, WorldSubmissionService $submissions): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$evolution = app(ArtworkEvolutionService::class);
$validated = $request->validate([
'title' => 'sometimes|string|max:255',
'description' => 'sometimes|nullable|string|max:5000',
'is_public' => 'sometimes|boolean',
'visibility' => 'sometimes|string|in:public,unlisted,private',
'mode' => 'sometimes|string|in:now,schedule',
'publish_at' => 'sometimes|nullable|string|date',
'timezone' => 'sometimes|nullable|string|max:64',
'category_id' => 'sometimes|nullable|integer|exists:categories,id',
'content_type_id' => 'sometimes|nullable|integer|exists:content_types,id',
'tags' => 'sometimes|array|max:' . (int) config('tags.max_user_tags', 30),
'tags.*' => 'string|max:64',
'title_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
'description_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
'tags_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
'category_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
'group' => 'sometimes|nullable|string|max:90',
'primary_author_user_id' => 'sometimes|nullable|integer|min:1',
'contributor_user_ids' => 'sometimes|array|max:20',
'contributor_user_ids.*' => 'integer|min:1',
'contributor_credits' => 'sometimes|array|max:20',
'contributor_credits.*.user_id' => 'required|integer|min:1',
'contributor_credits.*.credit_role' => 'nullable|string|max:80',
'contributor_credits.*.is_primary' => 'nullable|boolean',
'world_submissions' => 'sometimes|array|max:12',
'world_submissions.*.world_id' => 'required|integer|exists:worlds,id',
'world_submissions.*.note' => 'nullable|string|max:1000',
'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',
]);
$hasAttributionUpdates = array_key_exists('group', $validated)
|| array_key_exists('primary_author_user_id', $validated)
|| array_key_exists('contributor_user_ids', $validated)
|| array_key_exists('contributor_credits', $validated);
$hasEvolutionUpdates = array_key_exists('evolution_target_artwork_id', $validated)
|| array_key_exists('evolution_relation_type', $validated)
|| array_key_exists('evolution_note', $validated);
$worldSubmissionPayload = $validated['world_submissions'] ?? null;
$attributionPayload = [
'group' => $validated['group'] ?? $artwork->group?->slug,
'primary_author_user_id' => $validated['primary_author_user_id'] ?? $artwork->primary_author_user_id,
'contributor_user_ids' => $validated['contributor_user_ids'] ?? $artwork->contributors()->pluck('user_id')->all(),
'contributor_credits' => $validated['contributor_credits'] ?? $artwork->contributors()->get()->map(fn ($contributor): array => [
'user_id' => (int) $contributor->user_id,
'credit_role' => $contributor->credit_role,
'is_primary' => (bool) $contributor->is_primary,
])->values()->all(),
];
$visibility = (string) ($validated['visibility'] ?? ($artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE)));
$mode = (string) ($validated['mode'] ?? ($artwork->artwork_status === 'scheduled' ? 'schedule' : 'now'));
$timezone = array_key_exists('timezone', $validated)
? $validated['timezone']
: $artwork->artwork_timezone;
$publishAt = null;
if ($mode === 'schedule' && ! empty($validated['publish_at'])) {
try {
$publishAt = Carbon::parse($validated['publish_at'])->utc();
} catch (\Throwable) {
return response()->json(['errors' => ['publish_at' => ['Invalid publish date/time.']]], 422);
}
if ($publishAt->lte(now()->addMinute())) {
return response()->json(['errors' => ['publish_at' => ['Scheduled publish time must be at least 1 minute in the future.']]], 422);
}
} elseif ($mode === 'schedule') {
return response()->json(['errors' => ['publish_at' => ['Choose a date and time for scheduled publishing.']]], 422);
}
// Extract tags and category before updating core fields
$tags = $validated['tags'] ?? null;
$categoryId = $validated['category_id'] ?? null;
$contentTypeId = $validated['content_type_id'] ?? null;
$evolutionPayload = [
'target_artwork_id' => $validated['evolution_target_artwork_id'] ?? null,
'relation_type' => $validated['evolution_relation_type'] ?? null,
'note' => $validated['evolution_note'] ?? null,
];
unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits'], $validated['world_submissions']);
unset($validated['evolution_target_artwork_id'], $validated['evolution_relation_type'], $validated['evolution_note']);
$validated['visibility'] = $visibility;
$validated['artwork_timezone'] = $timezone;
if ($mode === 'schedule' && $publishAt) {
$validated['is_public'] = false;
$validated['is_approved'] = true;
$validated['publish_at'] = $publishAt;
$validated['published_at'] = null;
$validated['artwork_status'] = 'scheduled';
} else {
$validated['is_public'] = $visibility !== Artwork::VISIBILITY_PRIVATE;
$validated['is_approved'] = true;
$validated['publish_at'] = null;
$validated['artwork_status'] = 'published';
if (($validated['is_public'] ?? false) && ! $artwork->published_at) {
$validated['published_at'] = now();
}
if ($visibility === Artwork::VISIBILITY_PRIVATE) {
$validated['published_at'] = $artwork->published_at;
}
}
if ($categoryId === null && $contentTypeId !== null) {
$categoryId = $this->resolveCategoryIdForContentType((int) $contentTypeId);
}
$artwork->update($validated);
// Sync category
if ($categoryId !== null) {
$artwork->categories()->sync([(int) $categoryId]);
}
// Sync tags through the shared tag service so pivot source/usage rules stay valid.
if ($tags !== null) {
try {
$this->tagService->syncStudioTags(
$artwork,
$tags,
(string) ($validated['tags_source'] ?? 'manual')
);
} catch (ValidationException $exception) {
return response()->json(['errors' => $exception->errors()], 422);
}
}
if ($hasAttributionUpdates) {
$artwork = $attribution->apply($artwork->fresh(['group.members', 'contributors', 'primaryAuthor.profile']), $request->user(), $attributionPayload);
}
if ($hasEvolutionUpdates) {
try {
$evolution->syncPrimaryRelation($artwork->fresh(['group.members']), $request->user(), $evolutionPayload);
} catch (ValidationException $exception) {
return response()->json(['errors' => $exception->errors()], 422);
}
}
if ($worldSubmissionPayload !== null) {
try {
$submissions->syncForArtwork($artwork->fresh(), $request->user(), (array) $worldSubmissionPayload);
} catch (ValidationException $exception) {
return response()->json(['errors' => $exception->errors()], 422);
}
}
// 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
}
// Reload relationships for response
$artwork->load(['categories.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']);
$primaryCategory = $artwork->categories->first();
return response()->json([
'success' => true,
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'description' => $artwork->description,
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'artwork_timezone' => $artwork->artwork_timezone,
'slug' => $artwork->slug,
'group_slug' => $artwork->group?->slug,
'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id),
'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($contributorId): int => (int) $contributorId)->values()->all(),
'contributor_credits' => $artwork->contributors->map(fn ($contributor): array => [
'user_id' => (int) $contributor->user_id,
'credit_role' => $contributor->credit_role,
'is_primary' => (bool) $contributor->is_primary,
])->values()->all(),
'content_type_id' => $primaryCategory?->contentType?->id,
'category_id' => $primaryCategory?->id,
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
'title_source' => $artwork->title_source ?: 'manual',
'description_source' => $artwork->description_source ?: 'manual',
'tags_source' => $artwork->tags_source ?: 'manual',
'category_source' => $artwork->category_source ?: 'manual',
'evolution_relation' => $evolution->editorRelation($artwork, $request->user()),
],
'world_submission_options' => $submissions->artworkSubmissionOptions($artwork->fresh(['worldSubmissions.world', 'worldSubmissions.reviewer']), $request->user()),
]);
}
public function evolutionOptions(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$validated = $request->validate([
'search' => ['nullable', 'string', 'max:120'],
]);
$evolution = app(ArtworkEvolutionService::class);
return response()->json([
'data' => $evolution->manageableSearchOptions($artwork, $request->user(), (string) ($validated['search'] ?? '')),
'meta' => [
'selected' => $evolution->editorRelation($artwork, $request->user()),
],
]);
}
private function resolveCategoryIdForContentType(int $contentTypeId): ?int
{
$contentType = ContentType::query()->find($contentTypeId);
if (! $contentType) {
return null;
}
$category = $contentType->rootCategories()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
->first();
if (! $category) {
$category = Category::query()
->where('content_type_id', $contentType->id)
->where('is_active', true)
->orderByRaw('CASE WHEN parent_id IS NULL THEN 0 ELSE 1 END')
->orderBy('sort_order')
->orderBy('name')
->first();
}
return $category?->id;
}
/**
* POST /api/studio/artworks/{id}/toggle
* Toggle publish/unpublish/archive for a single artwork.
*/
public function toggle(Request $request, int $id): JsonResponse
{
$validator = Validator::make($request->all(), [
'action' => 'required|string|in:publish,unpublish,archive,unarchive',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$result = $this->bulkService->execute(
$request->user()->id,
$validator->validated()['action'],
[$id],
);
if ($result['success'] === 0) {
return response()->json(['error' => 'Action failed', 'details' => $result['errors']], 404);
}
return response()->json(['success' => true]);
}
/**
* GET /api/studio/artworks/{id}/analytics
* Analytics data for a single artwork.
*/
public function analytics(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()
->with(['stats', 'awardStat'])
->findOrFail($id);
$stats = $artwork->stats;
return response()->json([
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
],
'analytics' => [
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0),
],
]);
}
private function transformArtwork($artwork): array
{
if ($artwork instanceof Artwork) {
$artwork = $this->artworkPublication->publishIfDue($artwork);
}
$stats = $artwork->stats ?? null;
return [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg',
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
'is_approved' => (bool) $artwork->is_approved,
'published_at' => $artwork->published_at?->toIso8601String(),
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'created_at' => $artwork->created_at?->toIso8601String(),
'deleted_at' => $artwork->deleted_at?->toIso8601String(),
'category' => $artwork->categories->first()?->name,
'category_slug' => $artwork->categories->first()?->slug,
'tags' => $artwork->tags->pluck('slug')->values()->all(),
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
];
}
/**
* GET /api/studio/tags/search?q=...
* Search active tags for studio pickers, with empty-query fallback to popular tags.
*/
public function searchTags(Request $request): JsonResponse
{
$query = trim((string) $request->input('q'));
$tags = $this->tagDiscoveryService->searchSuggestions($query, 30);
return response()->json($tags);
}
/**
* POST /api/studio/artworks/{id}/replace-file
* Replace the artwork's primary image file — creates a new immutable version.
*
* Accepts an optional `change_note` text field alongside the file.
*/
public function replaceFile(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$previousSnapshot = $this->versioningService->captureArtworkSnapshot($artwork);
$request->validate([
'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB
'change_note' => 'sometimes|nullable|string|max:500',
]);
// ── Rate-limit gate (before expensive file processing) ────────────
try {
$this->versioningService->rateLimitCheck($request->user()->id, $artwork->id);
} catch (TooManyRequestsHttpException $e) {
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
}
$file = $request->file('file');
$tempPath = $file->getRealPath();
$hash = hash_file('sha256', $tempPath);
// Reject identical files early (before any disk writes)
if ($artwork->hash === $hash) {
return response()->json([
'success' => false,
'error' => 'The uploaded file is identical to the current version.',
], 422);
}
try {
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
$storage = app(\App\Services\Uploads\UploadStorageService::class);
$artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class);
// 1. Store original on disk (preserve extension when possible)
$originalAsset = $derivatives->storeOriginal($tempPath, $hash);
$originalPath = $originalAsset['local_path'];
$origFilename = basename($originalPath);
$originalRelative = $storage->sectionRelativePath('original', $hash, $origFilename);
$origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream';
$artworkFiles->upsert($artwork->id, 'orig', $originalRelative, $origMime, (int) filesize($originalPath));
// 2. Generate thumbnails (xs/sm/md/lg/xl)
$publicAssets = $derivatives->generatePublicDerivatives($tempPath, $hash);
foreach ($publicAssets as $variant => $asset) {
$relativePath = $storage->sectionRelativePath($variant, $hash, $hash . '.webp');
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) ($asset['size'] ?? 0));
}
// 3. Get new dimensions
$dims = @getimagesize($tempPath);
$width = is_array($dims) && isset($dims[0]) ? (int) $dims[0] : $artwork->width;
$height = is_array($dims) && isset($dims[1]) ? (int) $dims[1] : $artwork->height;
$size = (int) filesize($originalPath);
// 4. Update the artwork's file-serving fields (hash drives thumbnail URLs)
$origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: '');
$displayFileName = $origFilename;
$clientName = basename(str_replace('\\', '/', (string) $file->getClientOriginalName()));
$clientName = preg_replace('/[\x00-\x1F\x7F]/', '', (string) $clientName) ?? '';
$clientName = trim((string) $clientName);
if ($clientName !== '') {
$clientExt = strtolower((string) pathinfo($clientName, PATHINFO_EXTENSION));
if ($clientExt === '' && $origExt !== '') {
$clientName .= '.' . $origExt;
}
$displayFileName = $clientName;
}
$artwork->update([
'file_name' => $displayFileName,
'file_path' => '',
'file_size' => $size,
'mime_type' => $origMime,
'hash' => $hash,
'file_ext' => $origExt,
'thumb_ext' => 'webp',
'width' => max(1, $width),
'height' => max(1, $height),
]);
// 5. Create version record from the new full media snapshot.
$artwork->refresh();
$version = $this->versioningService->createVersionFromSnapshot(
$artwork,
$this->versioningService->captureArtworkSnapshot($artwork),
$request->user()->id,
$request->input('change_note'),
$previousSnapshot,
);
// 6. Reindex in Meilisearch (non-blocking)
try {
$this->searchIndexer->update($artwork);
} catch (\Throwable $e) {
Log::warning('ArtworkVersioningService: Meilisearch reindex failed', [
'artwork_id' => $artwork->id,
'error' => $e->getMessage(),
]);
}
// 7. CDN cache bust — purge thumbnail paths for the old hash
$this->purgeCdnCache($artwork, $hash);
return response()->json([
'success' => true,
'version_number' => $version->version_number,
'requires_reapproval' => (bool) $artwork->requires_reapproval,
] + $this->mediaPayload($artwork));
} catch (\Throwable $e) {
Log::error('replaceFile: processing error', [
'artwork_id' => $artwork->id,
'error' => $e->getMessage(),
]);
return response()->json(['success' => false, 'error' => 'File processing failed: ' . $e->getMessage()], 500);
}
}
public function reviseMedia(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$previousSnapshot = $this->versioningService->captureArtworkSnapshot($artwork);
$filesByVariant = $this->snapshotFilesByVariant($previousSnapshot);
$hasArchiveFile = array_key_exists('orig_archive', $filesByVariant);
$request->validate([
'cover_file' => 'sometimes|nullable|file|mimes:jpeg,jpg,png,webp|max:51200',
'archive_file' => 'sometimes|nullable|file|mimes:zip,rar,7z,tar,gz|max:204800',
'screenshot_files' => 'sometimes|array|max:4',
'screenshot_files.*' => 'file|mimes:jpeg,jpg,png,webp|max:51200',
'replace_shots' => 'sometimes|array|max:4',
'replace_shots.*' => 'file|mimes:jpeg,jpg,png,webp|max:51200',
'remove_shots' => 'sometimes|array|max:4',
'remove_shots.*' => 'integer|min:0|max:3',
'change_note' => 'sometimes|nullable|string|max:500',
]);
/** @var UploadedFile|null $coverFile */
$coverFile = $request->file('cover_file');
/** @var UploadedFile|null $archiveFile */
$archiveFile = $request->file('archive_file');
$screenshotFiles = array_values(array_filter((array) $request->file('screenshot_files', []), fn ($file): bool => $file instanceof UploadedFile));
$replaceShotFiles = array_filter((array) $request->file('replace_shots', []), fn ($file): bool => $file instanceof UploadedFile);
$removeShotIndexes = collect((array) $request->input('remove_shots', []))
->map(fn ($value): int => (int) $value)
->filter(fn (int $value): bool => $value >= 0)
->unique()
->values();
if (! $hasArchiveFile && $archiveFile) {
return response()->json([
'success' => false,
'error' => 'Archive package replacement is available only for archive artworks.',
], 422);
}
if (! $coverFile && ! $archiveFile && $screenshotFiles === [] && $replaceShotFiles === [] && $removeShotIndexes->isEmpty()) {
return response()->json([
'success' => false,
'error' => 'Choose a new cover screenshot, a new archive file, or extra screenshots first.',
], 422);
}
$existingScreenshots = collect($previousSnapshot['files'] ?? [])
->filter(fn (array $file): bool => str_starts_with((string) ($file['variant'] ?? ''), 'shot'))
->values();
$remainingScreenshotCount = max(0, $existingScreenshots->count() - $removeShotIndexes->count());
if (($remainingScreenshotCount + count($screenshotFiles)) > 4) {
return response()->json([
'success' => false,
'error' => 'Artworks can have up to 5 screenshots total: 1 main cover plus 4 additional screenshots.',
], 422);
}
try {
$this->versioningService->rateLimitCheck($request->user()->id, $artwork->id);
} catch (TooManyRequestsHttpException $e) {
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
}
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
$storage = app(\App\Services\Uploads\UploadStorageService::class);
$cleanupLocalPaths = [];
$cleanupObjectPaths = [];
try {
$coverDescriptor = null;
$coverHash = (string) ($previousSnapshot['artwork']['hash'] ?? $artwork->hash ?? '');
$width = (int) ($previousSnapshot['artwork']['width'] ?? $artwork->width ?? 0);
$height = (int) ($previousSnapshot['artwork']['height'] ?? $artwork->height ?? 0);
$publicAssets = $this->currentPublicAssetsFromSnapshot($previousSnapshot);
if ($coverFile) {
$coverHash = hash_file('sha256', $coverFile->getPathname());
$coverStored = $derivatives->storeOriginal($coverFile->getPathname(), $coverHash, $coverFile->getClientOriginalName());
$cleanupLocalPaths[] = $coverStored['local_path'];
$cleanupObjectPaths[] = $coverStored['object_path'];
$coverDescriptor = $this->storedOriginalDescriptor($coverStored, 'orig_image');
$publicAssets = $derivatives->generatePublicDerivatives($coverFile->getPathname(), $coverHash);
foreach ($publicAssets as $asset) {
$cleanupObjectPaths[] = (string) ($asset['path'] ?? '');
}
$dims = @getimagesize($coverFile->getPathname());
$width = is_array($dims) && isset($dims[0]) ? max(1, (int) $dims[0]) : max(1, (int) $artwork->width);
$height = is_array($dims) && isset($dims[1]) ? max(1, (int) $dims[1]) : max(1, (int) $artwork->height);
} else {
$coverDescriptor = $hasArchiveFile
? ($this->existingVariantDescriptor($filesByVariant['orig_image'] ?? null, 'orig_image') ?? $this->existingVariantDescriptor($filesByVariant['orig'] ?? null, 'orig'))
: $this->existingVariantDescriptor($filesByVariant['orig'] ?? null, 'orig');
}
if (! $coverDescriptor) {
return response()->json(['success' => false, 'error' => 'Unable to resolve the current cover screenshot.'], 422);
}
$archiveDescriptor = null;
if ($hasArchiveFile || $archiveFile) {
if ($archiveFile) {
$archiveHash = hash_file('sha256', $archiveFile->getPathname());
$archiveStored = $derivatives->storeOriginal($archiveFile->getPathname(), $archiveHash, $archiveFile->getClientOriginalName());
$cleanupLocalPaths[] = $archiveStored['local_path'];
$cleanupObjectPaths[] = $archiveStored['object_path'];
$archiveDescriptor = $this->storedOriginalDescriptor($archiveStored, 'orig_archive');
} else {
$archiveDescriptor = $this->existingVariantDescriptor($filesByVariant['orig_archive'] ?? null, 'orig_archive');
}
}
$newScreenshotDescriptors = [];
foreach ($screenshotFiles as $screenshotFile) {
$screenshotHash = hash_file('sha256', $screenshotFile->getPathname());
$screenshotStored = $derivatives->storeOriginal($screenshotFile->getPathname(), $screenshotHash, $screenshotFile->getClientOriginalName());
$cleanupLocalPaths[] = $screenshotStored['local_path'];
$cleanupObjectPaths[] = $screenshotStored['object_path'];
$newScreenshotDescriptors[] = $this->storedOriginalDescriptor($screenshotStored, '');
}
// Build per-slot replacement descriptors
$replaceShotDescriptors = [];
foreach ($replaceShotFiles as $slotIndex => $replaceShotFile) {
$replaceShotHash = hash_file('sha256', $replaceShotFile->getPathname());
$replaceShotStored = $derivatives->storeOriginal($replaceShotFile->getPathname(), $replaceShotHash, $replaceShotFile->getClientOriginalName());
$cleanupLocalPaths[] = $replaceShotStored['local_path'];
$cleanupObjectPaths[] = $replaceShotStored['object_path'];
$replaceShotDescriptors[(int) $slotIndex] = $this->storedOriginalDescriptor($replaceShotStored, '');
}
// Merge existing slots with per-slot replacements
$existingScreenshotDescriptors = $existingScreenshots
->map(function (array $file, int $index) use ($replaceShotDescriptors, $removeShotIndexes): ?array {
if ($removeShotIndexes->contains($index)) {
return null;
}
if (isset($replaceShotDescriptors[$index])) {
return $replaceShotDescriptors[$index];
}
return $this->existingVariantDescriptor($file, '');
})
->filter()
->values()
->all();
$allScreenshotDescriptors = [];
foreach (array_values(array_merge($existingScreenshotDescriptors, $newScreenshotDescriptors)) as $index => $descriptor) {
$descriptor['variant'] = 'shot' . ($index + 1);
$allScreenshotDescriptors[] = $descriptor;
}
$primaryDescriptor = $archiveDescriptor ?? $coverDescriptor;
$preferredFileName = $archiveFile?->getClientOriginalName()
?? ($archiveDescriptor ? (string) ($previousSnapshot['artwork']['file_name'] ?? $artwork->file_name) : $coverFile?->getClientOriginalName())
?? (string) ($previousSnapshot['artwork']['file_name'] ?? $artwork->file_name);
$snapshot = [
'artwork' => [
'file_name' => $this->resolvePreferredFileName($preferredFileName, (string) ($primaryDescriptor['ext'] ?? '')),
'file_path' => (string) ($primaryDescriptor['path'] ?? ''),
'hash' => $coverHash,
'file_ext' => (string) ($primaryDescriptor['ext'] ?? ''),
'thumb_ext' => 'webp',
'file_size' => (int) ($primaryDescriptor['size'] ?? 0),
'mime_type' => (string) ($primaryDescriptor['mime'] ?? 'application/octet-stream'),
'width' => max(1, $width),
'height' => max(1, $height),
],
'files' => array_values(array_filter(array_merge(
[[
'variant' => 'orig',
'path' => (string) ($primaryDescriptor['path'] ?? ''),
'mime' => (string) ($primaryDescriptor['mime'] ?? 'application/octet-stream'),
'size' => (int) ($primaryDescriptor['size'] ?? 0),
]],
[[
'variant' => 'orig_image',
'path' => (string) ($coverDescriptor['path'] ?? ''),
'mime' => (string) ($coverDescriptor['mime'] ?? 'application/octet-stream'),
'size' => (int) ($coverDescriptor['size'] ?? 0),
]],
$archiveDescriptor ? [[
'variant' => 'orig_archive',
'path' => (string) ($archiveDescriptor['path'] ?? ''),
'mime' => (string) ($archiveDescriptor['mime'] ?? 'application/octet-stream'),
'size' => (int) ($archiveDescriptor['size'] ?? 0),
]] : [],
collect($publicAssets)->map(fn (array $asset, string $variant): array => [
'variant' => $variant,
'path' => (string) ($asset['path'] ?? ''),
'mime' => (string) ($asset['mime'] ?? 'image/webp'),
'size' => (int) ($asset['size'] ?? 0),
])->values()->all(),
array_map(fn (array $descriptor): array => [
'variant' => (string) $descriptor['variant'],
'path' => (string) $descriptor['path'],
'mime' => (string) ($descriptor['mime'] ?? 'application/octet-stream'),
'size' => (int) ($descriptor['size'] ?? 0),
], $allScreenshotDescriptors),
), fn (array $file): bool => (string) ($file['variant'] ?? '') !== '' && (string) ($file['path'] ?? '') !== '')),
];
$this->versioningService->applySnapshot($artwork, $snapshot);
$artwork->refresh();
$version = $this->versioningService->createVersionFromSnapshot(
$artwork,
$snapshot,
$request->user()->id,
$request->input('change_note'),
$previousSnapshot,
);
try {
$this->searchIndexer->update($artwork);
} catch (\Throwable $exception) {
Log::warning('Archive media revision reindex failed', [
'artwork_id' => $artwork->id,
'error' => $exception->getMessage(),
]);
}
return response()->json([
'success' => true,
'version_number' => $version->version_number,
'requires_reapproval' => (bool) $artwork->requires_reapproval,
] + $this->mediaPayload($artwork));
} catch (\Throwable $exception) {
foreach ($cleanupLocalPaths as $path) {
$storage->deleteLocalFile($path);
}
foreach ($cleanupObjectPaths as $path) {
$storage->deleteObject($path);
}
Log::error('reviseMedia: processing error', [
'artwork_id' => $artwork->id,
'error' => $exception->getMessage(),
]);
return response()->json([
'success' => false,
'error' => 'Revision update failed: ' . $exception->getMessage(),
], 500);
}
}
/**
* GET /api/studio/artworks/{id}/versions
* Return version history for an artwork (newest first).
*/
public function versions(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$versions = $artwork->versions()->reorder()->orderByDesc('version_number')->get();
return response()->json([
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'version_count' => (int) ($artwork->version_count ?? 1),
],
'versions' => $versions->map(fn (ArtworkVersion $v) => [
'id' => $v->id,
'version_number' => $v->version_number,
'file_path' => $v->file_path,
'file_hash' => $v->file_hash,
'width' => $v->width,
'height' => $v->height,
'file_size' => $v->file_size,
'file_name' => (string) data_get($v->snapshot_json, 'artwork.file_name', ''),
'change_note' => $v->change_note,
'is_current' => $v->is_current,
'created_at' => $v->created_at?->toIso8601String(),
])->values(),
]);
}
/**
* POST /api/studio/artworks/{id}/restore/{version_id}
* Restore an earlier version (cloned as a new current version).
*/
public function restoreVersion(Request $request, int $id, int $versionId): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$version = ArtworkVersion::where('artwork_id', $artwork->id)->findOrFail($versionId);
if ($version->is_current) {
return response()->json(['success' => false, 'error' => 'This version is already the current version.'], 422);
}
try {
$newVersion = $this->versioningService->restoreVersion($version, $artwork, $request->user()->id);
$artwork->refresh();
// Reindex
try {
$this->searchIndexer->update($artwork);
} catch (\Throwable) {}
return response()->json([
'success' => true,
'version_number' => $newVersion->version_number,
'message' => "Version {$version->version_number} has been restored as version {$newVersion->version_number}.",
] + $this->mediaPayload($artwork));
} catch (TooManyRequestsHttpException $e) {
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
} catch (\Throwable $e) {
return response()->json(['success' => false, 'error' => 'Restore failed: ' . $e->getMessage()], 500);
}
}
/**
* Purge CDN thumbnail cache for the artwork.
*
* This is best-effort; failures are logged but never fatal.
* Configure a CDN purge webhook via ARTWORK_CDN_PURGE_URL if needed.
*/
private function purgeCdnCache(\App\Models\Artwork $artwork, string $oldHash): void
{
try {
$this->cdnPurge->purgeArtworkHashVariants($oldHash, 'webp', ['xs', 'sm', 'md', 'lg', 'xl', 'sq'], [
'artwork_id' => $artwork->id,
'reason' => 'artwork_file_replaced',
]);
} catch (\Throwable $e) {
Log::warning('CDN cache purge failed', ['artwork_id' => $artwork->id, 'error' => $e->getMessage()]);
}
}
/**
* @param array<string, mixed> $snapshot
* @return array<string, array<string, mixed>>
*/
private function snapshotFilesByVariant(array $snapshot): array
{
return collect($snapshot['files'] ?? [])
->filter(fn ($file): bool => is_array($file) && (string) ($file['variant'] ?? '') !== '')
->mapWithKeys(fn (array $file): array => [(string) $file['variant'] => $file])
->all();
}
/**
* @param array<string, mixed> $snapshot
* @return array<string, array{path: string, mime: string, size: int}>
*/
private function currentPublicAssetsFromSnapshot(array $snapshot): array
{
return collect($snapshot['files'] ?? [])
->filter(fn ($file): bool => is_array($file) && in_array((string) ($file['variant'] ?? ''), ['xs', 'sm', 'md', 'lg', 'xl', 'sq'], true))
->mapWithKeys(fn (array $file): array => [
(string) $file['variant'] => [
'path' => (string) ($file['path'] ?? ''),
'mime' => (string) ($file['mime'] ?? 'image/webp'),
'size' => (int) ($file['size'] ?? 0),
],
])
->all();
}
/**
* @param array<string, mixed>|null $file
* @return array<string, mixed>|null
*/
private function existingVariantDescriptor(?array $file, string $fallbackVariant): ?array
{
if (! is_array($file) || (string) ($file['path'] ?? '') === '') {
return null;
}
$path = (string) ($file['path'] ?? '');
return [
'variant' => $fallbackVariant !== '' ? $fallbackVariant : (string) ($file['variant'] ?? ''),
'path' => $path,
'mime' => (string) ($file['mime'] ?? 'application/octet-stream'),
'size' => (int) ($file['size'] ?? 0),
'ext' => strtolower((string) pathinfo($path, PATHINFO_EXTENSION)),
];
}
/**
* @param array<string, mixed> $stored
* @return array<string, mixed>
*/
private function storedOriginalDescriptor(array $stored, string $variant): array
{
return [
'variant' => $variant,
'path' => (string) ($stored['object_path'] ?? ''),
'mime' => (string) ($stored['mime'] ?? 'application/octet-stream'),
'size' => (int) ($stored['size'] ?? 0),
'ext' => strtolower((string) ($stored['ext'] ?? pathinfo((string) ($stored['object_path'] ?? ''), PATHINFO_EXTENSION))),
];
}
private function resolvePreferredFileName(?string $preferredFileName, string $ext): string
{
$candidate = basename(str_replace('\\', '/', (string) ($preferredFileName ?? '')));
$candidate = preg_replace('/[\x00-\x1F\x7F]/', '', (string) $candidate) ?? '';
$candidate = trim((string) $candidate);
if ($candidate === '') {
$candidate = 'artwork';
}
$candidateExt = strtolower((string) pathinfo($candidate, PATHINFO_EXTENSION));
if ($candidateExt === '' && $ext !== '') {
$candidate .= '.' . ltrim($ext, '.');
}
return $candidate;
}
/**
* @return array<string, mixed>
*/
private function mediaPayload(Artwork $artwork): array
{
$artwork->refresh();
$snapshot = $this->versioningService->captureArtworkSnapshot($artwork);
$filesByVariant = $this->snapshotFilesByVariant($snapshot);
return [
'thumb_url' => $artwork->thumbUrl('md'),
'thumb_url_lg' => $artwork->thumbUrl('lg'),
'width' => (int) ($artwork->width ?? 0),
'height' => (int) ($artwork->height ?? 0),
'file_size' => (int) ($artwork->file_size ?? 0),
'file_name' => (string) ($artwork->file_name ?? ''),
'file_ext' => (string) ($artwork->file_ext ?? ''),
'mime_type' => (string) ($artwork->mime_type ?? ''),
'has_archive_file' => array_key_exists('orig_archive', $filesByVariant),
'screenshots' => $this->screenshotAssetsFromSnapshot($snapshot),
];
}
/**
* @param array<string, mixed> $snapshot
* @return array<int, array<string, mixed>>
*/
private function screenshotAssetsFromSnapshot(array $snapshot): array
{
$base = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
return collect($snapshot['files'] ?? [])
->filter(fn ($file): bool => is_array($file) && str_starts_with((string) ($file['variant'] ?? ''), 'shot') && (string) ($file['path'] ?? '') !== '')
->values()
->map(function (array $file, int $index) use ($base): array {
$path = trim((string) ($file['path'] ?? ''), '/');
$url = $base . '/' . $path;
return [
'id' => (string) ($file['variant'] ?? ('shot' . ($index + 1))),
'label' => 'Screenshot ' . ($index + 1),
'url' => $url,
'thumb_url' => $url,
'mime_type' => (string) ($file['mime'] ?? 'image/jpeg'),
'size' => (int) ($file['size'] ?? 0),
];
})
->all();
}
}