513 lines
20 KiB
PHP
513 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Studio;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\ArtworkVersion;
|
|
use App\Services\ArtworkSearchIndexer;
|
|
use App\Services\ArtworkVersioningService;
|
|
use App\Services\Studio\StudioArtworkQueryService;
|
|
use App\Services\Studio\StudioBulkActionService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Validator;
|
|
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,
|
|
) {}
|
|
|
|
/**
|
|
* GET /api/studio/artworks
|
|
* List artworks with search, filter, sort, pagination.
|
|
*/
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$userId = $request->user()->id;
|
|
|
|
$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): JsonResponse
|
|
{
|
|
$artwork = $request->user()->artworks()->findOrFail($id);
|
|
|
|
$validated = $request->validate([
|
|
'title' => 'sometimes|string|max:255',
|
|
'description' => 'sometimes|nullable|string|max:5000',
|
|
'is_public' => 'sometimes|boolean',
|
|
'category_id' => 'sometimes|nullable|integer|exists:categories,id',
|
|
'tags' => 'sometimes|array|max:15',
|
|
'tags.*' => 'string|max:64',
|
|
]);
|
|
|
|
if (isset($validated['is_public'])) {
|
|
if ($validated['is_public'] && !$artwork->is_public) {
|
|
$validated['published_at'] = $artwork->published_at ?? now();
|
|
}
|
|
}
|
|
|
|
// Extract tags and category before updating core fields
|
|
$tags = $validated['tags'] ?? null;
|
|
$categoryId = $validated['category_id'] ?? null;
|
|
unset($validated['tags'], $validated['category_id']);
|
|
|
|
$artwork->update($validated);
|
|
|
|
// Sync category
|
|
if ($categoryId !== null) {
|
|
$artwork->categories()->sync([(int) $categoryId]);
|
|
}
|
|
|
|
// Sync tags (by slug/name)
|
|
if ($tags !== null) {
|
|
$tagIds = [];
|
|
foreach ($tags as $tagSlug) {
|
|
$tag = \App\Models\Tag::firstOrCreate(
|
|
['slug' => \Illuminate\Support\Str::slug($tagSlug)],
|
|
['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0]
|
|
);
|
|
$tagIds[$tag->id] = ['source' => 'studio_edit', 'confidence' => 1.0];
|
|
}
|
|
$artwork->tags()->sync($tagIds);
|
|
}
|
|
|
|
// Reindex in Meilisearch
|
|
try {
|
|
$artwork->searchable();
|
|
} catch (\Throwable $e) {
|
|
// Meilisearch may be unavailable
|
|
}
|
|
|
|
// Reload relationships for response
|
|
$artwork->load(['categories.contentType', 'tags']);
|
|
$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,
|
|
'slug' => $artwork->slug,
|
|
'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(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
$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,
|
|
'is_approved' => (bool) $artwork->is_approved,
|
|
'published_at' => $artwork->published_at?->toIso8601String(),
|
|
'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 by name for the bulk tag picker.
|
|
*/
|
|
public function searchTags(Request $request): JsonResponse
|
|
{
|
|
$query = trim((string) $request->input('q'));
|
|
|
|
$tags = \App\Models\Tag::query()
|
|
->where('is_active', true)
|
|
->when($query !== '', fn ($q) => $q->where('name', 'LIKE', "%{$query}%"))
|
|
->orderByDesc('usage_count')
|
|
->limit(30)
|
|
->get(['id', 'name', 'slug', 'usage_count']);
|
|
|
|
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);
|
|
|
|
$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)
|
|
$originalPath = $derivatives->storeOriginal($tempPath, $hash);
|
|
$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)
|
|
$publicAbsolute = $derivatives->generatePublicDerivatives($tempPath, $hash);
|
|
foreach ($publicAbsolute as $variant => $absolutePath) {
|
|
$relativePath = $storage->sectionRelativePath($variant, $hash, $hash . '.webp');
|
|
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath));
|
|
}
|
|
|
|
// 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, apply ranking protection, audit log
|
|
$version = $this->versioningService->createNewVersion(
|
|
$artwork,
|
|
$originalRelative,
|
|
$hash,
|
|
max(1, $width),
|
|
max(1, $height),
|
|
$size,
|
|
$request->user()->id,
|
|
$request->input('change_note'),
|
|
);
|
|
|
|
// 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,
|
|
'thumb_url' => $artwork->thumbUrl('md'),
|
|
'thumb_url_lg' => $artwork->thumbUrl('lg'),
|
|
'width' => $artwork->width,
|
|
'height' => $artwork->height,
|
|
'file_size' => $artwork->file_size,
|
|
'version_number' => $version->version_number,
|
|
'requires_reapproval' => (bool) $artwork->requires_reapproval,
|
|
]);
|
|
} 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
'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);
|
|
|
|
// Sync artwork file fields back to restored version dimensions
|
|
$artwork->update([
|
|
'width' => max(1, (int) $version->width),
|
|
'height' => max(1, (int) $version->height),
|
|
'file_size' => (int) $version->file_size,
|
|
]);
|
|
|
|
$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}.",
|
|
]);
|
|
} 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 {
|
|
$purgeUrl = config('cdn.purge_url');
|
|
if (empty($purgeUrl)) {
|
|
Log::debug('CDN purge skipped — cdn.purge_url not configured', ['artwork_id' => $artwork->id]);
|
|
return;
|
|
}
|
|
|
|
$paths = array_map(
|
|
fn (string $size) => "/thumbs/{$oldHash}/{$size}.webp",
|
|
['sm', 'md', 'lg', 'xl']
|
|
);
|
|
|
|
\Illuminate\Support\Facades\Http::timeout(5)->post($purgeUrl, ['paths' => $paths]);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('CDN cache purge failed', ['artwork_id' => $artwork->id, 'error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
}
|