diff --git a/app/Http/Controllers/Api/UploadVisionSuggestController.php b/app/Http/Controllers/Api/UploadVisionSuggestController.php new file mode 100644 index 00000000..cea8fb43 --- /dev/null +++ b/app/Http/Controllers/Api/UploadVisionSuggestController.php @@ -0,0 +1,212 @@ +json(['tags' => [], 'vision_enabled' => false]); + } + + $artwork = Artwork::query()->findOrFail($id); + $this->authorizeOrNotFound($request->user(), $artwork); + + $imageUrl = $this->buildImageUrl((string) $artwork->hash); + if ($imageUrl === null) { + return response()->json([ + 'tags' => [], + 'vision_enabled' => true, + 'reason' => 'image_url_unavailable', + ]); + } + + $gatewayBase = trim((string) config('vision.gateway.base_url', config('vision.clip.base_url', ''))); + if ($gatewayBase === '') { + return response()->json([ + 'tags' => [], + 'vision_enabled' => true, + 'reason' => 'gateway_not_configured', + ]); + } + + $url = rtrim($gatewayBase, '/') . '/analyze/all'; + $limit = min(20, max(5, (int) ($request->query('limit', 10)))); + $timeout = (int) config('vision.gateway.timeout_seconds', 10); + $cTimeout = (int) config('vision.gateway.connect_timeout_seconds', 3); + $ref = (string) Str::uuid(); + + try { + /** @var \Illuminate\Http\Client\Response $response */ + $response = Http::acceptJson() + ->connectTimeout(max(1, $cTimeout)) + ->timeout(max(1, $timeout)) + ->withHeaders(['X-Request-ID' => $ref]) + ->post($url, [ + 'url' => $imageUrl, + 'limit' => $limit, + ]); + + if (! $response->ok()) { + Log::warning('vision-suggest: non-ok response', [ + 'ref' => $ref, + 'artwork_id' => $id, + 'status' => $response->status(), + 'body' => Str::limit($response->body(), 400), + ]); + + return response()->json([ + 'tags' => [], + 'vision_enabled' => true, + 'reason' => 'gateway_error_' . $response->status(), + ]); + } + + $tags = $this->parseGatewayResponse($response->json()); + + return response()->json([ + 'tags' => $tags, + 'vision_enabled' => true, + 'source' => 'gateway_sync', + ]); + + } catch (\Throwable $e) { + Log::warning('vision-suggest: request failed', [ + 'ref' => $ref, + 'artwork_id' => $id, + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'tags' => [], + 'vision_enabled' => true, + 'reason' => 'gateway_exception', + ]); + } + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private function buildImageUrl(string $hash): ?string + { + $base = rtrim((string) config('cdn.files_url', ''), '/'); + if ($base === '') { + return null; + } + + $clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash)); + $clean = str_pad($clean, 6, '0'); + $seg = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00']; + $variant = (string) config('vision.image_variant', 'md'); + + return $base . '/img/' . implode('/', $seg) . '/' . $variant . '.webp'; + } + + /** + * Parse the /analyze/all gateway response. + * + * The gateway returns a unified object: + * { clip: [{tag, confidence}], blip: ["caption1"], yolo: [{tag, confidence}] } + * or a flat list of tags directly. + * + * @param mixed $json + * @return array + */ + private function parseGatewayResponse(mixed $json): array + { + $raw = []; + + if (! is_array($json)) { + return []; + } + + // Unified gateway response + if (isset($json['clip']) && is_array($json['clip'])) { + foreach ($json['clip'] as $item) { + $raw[] = ['tag' => $item['tag'] ?? $item['name'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'clip']; + } + } + + if (isset($json['yolo']) && is_array($json['yolo'])) { + foreach ($json['yolo'] as $item) { + $raw[] = ['tag' => $item['tag'] ?? $item['label'] ?? $item['name'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'yolo']; + } + } + + // Flat lists + if ($raw === []) { + $list = $json['tags'] ?? $json['data'] ?? $json; + if (is_array($list)) { + foreach ($list as $item) { + if (is_array($item)) { + $raw[] = ['tag' => $item['tag'] ?? $item['name'] ?? $item['label'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'vision']; + } elseif (is_string($item)) { + $raw[] = ['tag' => $item, 'confidence' => null, 'source' => 'vision']; + } + } + } + } + + // Deduplicate by slug, keep highest confidence + $bySlug = []; + foreach ($raw as $r) { + $slug = $this->normalizer->normalize((string) ($r['tag'] ?? '')); + if ($slug === '') { + continue; + } + $conf = isset($r['confidence']) && is_numeric($r['confidence']) ? (float) $r['confidence'] : null; + + if (! isset($bySlug[$slug]) || ($conf !== null && $conf > (float) ($bySlug[$slug]['confidence'] ?? 0))) { + $bySlug[$slug] = [ + 'name' => ucwords(str_replace(['-', '_'], ' ', $slug)), + 'slug' => $slug, + 'confidence' => $conf, + 'source' => $r['source'] ?? 'vision', + 'is_ai' => true, + ]; + } + } + + // Sort by confidence desc + $sorted = array_values($bySlug); + usort($sorted, static fn ($a, $b) => ($b['confidence'] ?? 0) <=> ($a['confidence'] ?? 0)); + + return $sorted; + } + + private function authorizeOrNotFound(mixed $user, Artwork $artwork): void + { + if (! $user) { + abort(404); + } + if ((int) $artwork->user_id !== (int) $user->id) { + abort(404); + } + } +} diff --git a/app/Http/Controllers/Studio/StudioArtworksApiController.php b/app/Http/Controllers/Studio/StudioArtworksApiController.php index 4f6db964..82e0f3d8 100644 --- a/app/Http/Controllers/Studio/StudioArtworksApiController.php +++ b/app/Http/Controllers/Studio/StudioArtworksApiController.php @@ -5,11 +5,16 @@ 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\Validator; +use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; /** * JSON API endpoints for the Studio artwork manager. @@ -19,6 +24,8 @@ final class StudioArtworksApiController extends Controller public function __construct( private readonly StudioArtworkQueryService $queryService, private readonly StudioBulkActionService $bulkService, + private readonly ArtworkVersioningService $versioningService, + private readonly ArtworkSearchIndexer $searchIndexer, ) {} /** @@ -274,50 +281,66 @@ final class StudioArtworksApiController extends Controller /** * POST /api/studio/artworks/{id}/replace-file - * Replace the artwork's primary image file and regenerate derivatives. + * 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', // 50MB + 'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB + 'change_note' => 'sometimes|nullable|string|max:500', ]); - $file = $request->file('file'); - $tempPath = $file->getRealPath(); + // ── 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); + } - // Compute SHA-256 hash - $hash = hash_file('sha256', $tempPath); + $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); + $derivatives = app(\App\Services\Uploads\UploadDerivativesService::class); + $storage = app(\App\Services\Uploads\UploadStorageService::class); $artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class); - // Store original - $originalPath = $derivatives->storeOriginal($tempPath, $hash); + // 1. Store original on disk + $originalPath = $derivatives->storeOriginal($tempPath, $hash); $originalRelative = $storage->sectionRelativePath('originals', $hash, 'orig.webp'); $artworkFiles->upsert($artwork->id, 'orig', $originalRelative, 'image/webp', (int) filesize($originalPath)); - // Generate public derivatives + // 2. Generate public derivatives (thumbnails) $publicAbsolute = $derivatives->generatePublicDerivatives($tempPath, $hash); foreach ($publicAbsolute as $variant => $absolutePath) { - $filename = $variant . '.webp'; - $relativePath = $storage->publicRelativePath($hash, $filename); + $relativePath = $storage->publicRelativePath($hash, $variant . '.webp'); $artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath)); } - // Get dimensions - $dimensions = @getimagesize($tempPath); - $width = is_array($dimensions) && isset($dimensions[0]) ? (int) $dimensions[0] : $artwork->width; - $height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : $artwork->height; + // 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); - // Update artwork record + // 4. Update the artwork's file-serving fields (hash drives thumbnail URLs) $artwork->update([ 'file_name' => 'orig.webp', 'file_path' => '', - 'file_size' => (int) filesize($originalPath), + 'file_size' => $size, 'mime_type' => 'image/webp', 'hash' => $hash, 'file_ext' => 'webp', @@ -326,24 +349,145 @@ final class StudioArtworksApiController extends Controller '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 { - $artwork->searchable(); + $this->searchIndexer->update($artwork); } catch (\Throwable) {} 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, + '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' => 'File processing failed: ' . $e->getMessage(), - ], 500); + 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()]); } } } diff --git a/app/Http/Controllers/Studio/StudioController.php b/app/Http/Controllers/Studio/StudioController.php index fa5ebc74..7bdfcd7a 100644 --- a/app/Http/Controllers/Studio/StudioController.php +++ b/app/Http/Controllers/Studio/StudioController.php @@ -97,6 +97,9 @@ final class StudioController extends Controller 'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null, 'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug])->values()->all(), 'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(), + // Versioning + 'version_count' => (int) ($artwork->version_count ?? 1), + 'requires_reapproval' => (bool) $artwork->requires_reapproval, ], 'contentTypes' => $this->getCategories(), ]); diff --git a/app/Models/Artwork.php b/app/Models/Artwork.php index 20bf16fe..aca834e2 100644 --- a/app/Models/Artwork.php +++ b/app/Models/Artwork.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use App\Services\ThumbnailService; use Illuminate\Support\Facades\DB; use Laravel\Scout\Searchable; @@ -47,13 +48,20 @@ class Artwork extends Model 'published_at', 'hash', 'thumb_ext', - 'file_ext' + 'file_ext', + // Versioning + 'current_version_id', + 'version_count', + 'version_updated_at', + 'requires_reapproval', ]; protected $casts = [ - 'is_public' => 'boolean', - 'is_approved' => 'boolean', - 'published_at' => 'datetime', + 'is_public' => 'boolean', + 'is_approved' => 'boolean', + 'published_at' => 'datetime', + 'version_updated_at' => 'datetime', + 'requires_reapproval' => 'boolean', ]; /** @@ -193,6 +201,18 @@ class Artwork extends Model return $this->hasMany(ArtworkAward::class); } + /** All file versions for this artwork (oldest first). */ + public function versions(): HasMany + { + return $this->hasMany(ArtworkVersion::class)->orderBy('version_number'); + } + + /** The currently active version record. */ + public function currentVersion(): BelongsTo + { + return $this->belongsTo(ArtworkVersion::class, 'current_version_id'); + } + public function awardStat(): HasOne { return $this->hasOne(ArtworkAwardStat::class); diff --git a/app/Models/ArtworkVersion.php b/app/Models/ArtworkVersion.php new file mode 100644 index 00000000..365b2aae --- /dev/null +++ b/app/Models/ArtworkVersion.php @@ -0,0 +1,44 @@ + 'boolean', + 'version_number' => 'integer', + 'width' => 'integer', + 'height' => 'integer', + 'file_size' => 'integer', + ]; + + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class); + } +} diff --git a/app/Models/ArtworkVersionEvent.php b/app/Models/ArtworkVersionEvent.php new file mode 100644 index 00000000..726983ba --- /dev/null +++ b/app/Models/ArtworkVersionEvent.php @@ -0,0 +1,35 @@ +belongsTo(Artwork::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Services/ArtworkVersioningService.php b/app/Services/ArtworkVersioningService.php new file mode 100644 index 00000000..020806f5 --- /dev/null +++ b/app/Services/ArtworkVersioningService.php @@ -0,0 +1,248 @@ +rateLimitCheck($userId, $artwork->id); + + // 2. Reject identical file + if ($artwork->hash === $fileHash) { + throw new \RuntimeException('The uploaded file is identical to the current version. No new version created.'); + } + + return DB::transaction(function () use ( + $artwork, $filePath, $fileHash, $width, $height, $fileSize, $userId, $changeNote + ): ArtworkVersion { + // 3. Determine next version number + $nextNumber = ($artwork->version_count ?? 1) + 1; + + // 4. Mark all previous versions as not current + $artwork->versions()->update(['is_current' => false]); + + // 5. Insert new version row + $version = ArtworkVersion::create([ + 'artwork_id' => $artwork->id, + 'version_number' => $nextNumber, + 'file_path' => $filePath, + 'file_hash' => $fileHash, + 'width' => $width, + 'height' => $height, + 'file_size' => $fileSize, + 'change_note' => $changeNote, + 'is_current' => true, + ]); + + // 6. Check whether moderation re-review is required + $needsReapproval = $this->shouldRequireReapproval($artwork, $width, $height); + + // 7. Update artwork metadata (no engagement data touched) + $artwork->update([ + 'current_version_id' => $version->id, + 'version_count' => $nextNumber, + 'version_updated_at' => now(), + 'requires_reapproval' => $needsReapproval, + ]); + + // 8. Ranking protection — apply small decay + $this->applyRankingProtection($artwork); + + // 9. Audit log + ArtworkVersionEvent::create([ + 'artwork_id' => $artwork->id, + 'user_id' => $userId, + 'action' => 'create_version', + 'version_id' => $version->id, + ]); + + // 10. Increment hourly/daily counters for rate limiting + $this->incrementRateLimitCounters($userId, $artwork->id); + + return $version; + }); + } + + /** + * Restore a previous version by cloning it as a new (current) version. + * + * The restored file is treated as a brand-new version so the history + * remains strictly append-only and the version counter always increases. + * + * @throws TooManyRequestsHttpException When rate limit is exceeded. + */ + public function restoreVersion( + ArtworkVersion $version, + Artwork $artwork, + int $userId, + ): ArtworkVersion { + return $this->createNewVersion( + $artwork, + $version->file_path, + $version->file_hash, + (int) $version->width, + (int) $version->height, + (int) $version->file_size, + $userId, + "Restored from version {$version->version_number}", + ); + } + + /** + * Decide whether the new file warrants a moderation re-check. + * + * Triggers when either dimension changes by more than the threshold. + */ + public function shouldRequireReapproval(Artwork $artwork, int $newWidth, int $newHeight): bool + { + // First version upload — no existing dimensions to compare + if (!$artwork->width || !$artwork->height) { + return false; + } + + $widthChange = abs($newWidth - $artwork->width) / max($artwork->width, 1); + $heightChange = abs($newHeight - $artwork->height) / max($artwork->height, 1); + + return $widthChange > self::DIMENSION_CHANGE_THRESHOLD + || $heightChange > self::DIMENSION_CHANGE_THRESHOLD; + } + + /** + * Apply a small protective decay (7 %) to ranking and heat scores. + * + * This prevents creators from gaming the ranking algorithm by rapidly + * cycling file versions to refresh discovery signals. + * Engagement totals (views, favourites, downloads) are NOT touched. + */ + public function applyRankingProtection(Artwork $artwork): void + { + try { + DB::table('artwork_stats') + ->where('artwork_id', $artwork->id) + ->update([ + 'ranking_score' => DB::raw('ranking_score * ' . self::RANKING_DECAY_FACTOR), + 'heat_score' => DB::raw('heat_score * ' . self::RANKING_DECAY_FACTOR), + 'engagement_velocity' => DB::raw('engagement_velocity * ' . self::RANKING_DECAY_FACTOR), + ]); + } catch (\Throwable $e) { + // Non-fatal — log and continue so the version is still saved. + Log::warning('ArtworkVersioningService: ranking protection failed', [ + 'artwork_id' => $artwork->id, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Throw TooManyRequestsHttpException when the user has exceeded either: + * • 3 replacements per hour for this artwork + * • 10 replacements per day for this user (across all artworks) + */ + public function rateLimitCheck(int $userId, int $artworkId): void + { + $hourKey = "artwork_version:hour:{$userId}:{$artworkId}"; + $dayKey = "artwork_version:day:{$userId}"; + + $hourCount = (int) Cache::get($hourKey, 0); + $dayCount = (int) Cache::get($dayKey, 0); + + if ($hourCount >= self::MAX_PER_HOUR) { + throw new TooManyRequestsHttpException( + 3600, + 'You have replaced this artwork too many times in the last hour. Please wait before trying again.' + ); + } + + if ($dayCount >= self::MAX_PER_DAY) { + throw new TooManyRequestsHttpException( + 86400, + 'You have reached the daily replacement limit. Please wait until tomorrow.' + ); + } + } + + // ── Private helpers ──────────────────────────────────────────────────── + + private function incrementRateLimitCounters(int $userId, int $artworkId): void + { + $hourKey = "artwork_version:hour:{$userId}:{$artworkId}"; + $dayKey = "artwork_version:day:{$userId}"; + + // Hourly counter — expires in 1 hour + if (Cache::has($hourKey)) { + Cache::increment($hourKey); + } else { + Cache::put($hourKey, 1, 3600); + } + + // Daily counter — expires at midnight (or 24 hours from first hit) + if (Cache::has($dayKey)) { + Cache::increment($dayKey); + } else { + Cache::put($dayKey, 1, 86400); + } + } +} diff --git a/config/cdn.php b/config/cdn.php index 99de3cf3..7b7d809a 100644 --- a/config/cdn.php +++ b/config/cdn.php @@ -3,6 +3,15 @@ declare(strict_types=1); return [ - 'files_url' => env('FILES_CDN_URL', 'https://files.skinbase.org'), + 'files_url' => env('FILES_CDN_URL', 'https://files.skinbase.org'), 'avatar_url' => env('AVATAR_CDN_URL', 'https://files.skinbase.org'), + + /** + * Optional CDN purge webhook URL. + * When set, the artwork versioning system will POST { paths: [...] } + * after every file replacement to bust stale thumbnails. + * + * Example: https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache + */ + 'purge_url' => env('CDN_PURGE_URL', null), ]; diff --git a/config/vision.php b/config/vision.php index fdd787d3..c8452296 100644 --- a/config/vision.php +++ b/config/vision.php @@ -32,6 +32,18 @@ return [ // Which derivative variant to send to vision services. 'image_variant' => env('VISION_IMAGE_VARIANT', 'md'), + /* + |-------------------------------------------------------------------------- + | Vision Gateway (aggregates CLIP + BLIP + YOLO via /analyze/all) + |-------------------------------------------------------------------------- + | Falls back to CLIP base_url when VISION_GATEWAY_URL is not set. + */ + 'gateway' => [ + 'base_url' => env('VISION_GATEWAY_URL', env('CLIP_BASE_URL', '')), + 'timeout_seconds' => (int) env('VISION_GATEWAY_TIMEOUT', 10), + 'connect_timeout_seconds'=> (int) env('VISION_GATEWAY_CONNECT_TIMEOUT', 3), + ], + /* |-------------------------------------------------------------------------- | LM Studio – local multimodal inference (tag generation) diff --git a/database/migrations/2026_03_01_000001_create_artwork_versions_table.php b/database/migrations/2026_03_01_000001_create_artwork_versions_table.php new file mode 100644 index 00000000..730bebcd --- /dev/null +++ b/database/migrations/2026_03_01_000001_create_artwork_versions_table.php @@ -0,0 +1,36 @@ +id(); + $table->unsignedBigInteger('artwork_id')->index(); + + $table->unsignedInteger('version_number'); + $table->string('file_path'); + $table->string('file_hash', 64); + $table->unsignedInteger('width')->nullable(); + $table->unsignedInteger('height')->nullable(); + $table->unsignedBigInteger('file_size')->nullable(); + $table->text('change_note')->nullable(); + $table->boolean('is_current')->default(false); + $table->timestamps(); + + $table->foreign('artwork_id', 'fk_artwork_versions_artwork') + ->references('id')->on('artworks')->cascadeOnDelete(); + + $table->unique(['artwork_id', 'version_number'], 'uq_artwork_version'); + $table->index(['artwork_id', 'is_current'], 'idx_artwork_version_current'); + }); + } + + public function down(): void + { + Schema::dropIfExists('artwork_versions'); + } +}; diff --git a/database/migrations/2026_03_01_000002_add_versioning_columns_to_artworks_table.php b/database/migrations/2026_03_01_000002_add_versioning_columns_to_artworks_table.php new file mode 100644 index 00000000..16494f65 --- /dev/null +++ b/database/migrations/2026_03_01_000002_add_versioning_columns_to_artworks_table.php @@ -0,0 +1,37 @@ +unsignedBigInteger('current_version_id')->nullable()->index()->after('hash'); + + // Running count of how many versions exist + $table->unsignedInteger('version_count')->default(1)->after('current_version_id'); + + // Last time the file was replaced + $table->timestamp('version_updated_at')->nullable()->after('version_count'); + + // Signals that a moderator must approve the new file + $table->boolean('requires_reapproval')->default(false)->after('version_updated_at'); + }); + } + + public function down(): void + { + Schema::table('artworks', function (Blueprint $table) { + $table->dropIndex(['current_version_id']); + $table->dropColumn([ + 'current_version_id', + 'version_count', + 'version_updated_at', + 'requires_reapproval', + ]); + }); + } +}; diff --git a/database/migrations/2026_03_01_000003_create_artwork_version_events_table.php b/database/migrations/2026_03_01_000003_create_artwork_version_events_table.php new file mode 100644 index 00000000..7dafca06 --- /dev/null +++ b/database/migrations/2026_03_01_000003_create_artwork_version_events_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('artwork_id')->index(); + $table->unsignedBigInteger('user_id')->index(); + $table->string('action', 50); // create_version | restore_version + $table->unsignedBigInteger('version_id')->nullable(); + $table->timestamps(); + + $table->foreign('artwork_id', 'fk_avevt_artwork') + ->references('id')->on('artworks')->cascadeOnDelete(); + $table->foreign('user_id', 'fk_avevt_user') + ->references('id')->on('users')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('artwork_version_events'); + } +}; diff --git a/resources/js/Pages/Studio/StudioArtworkEdit.jsx b/resources/js/Pages/Studio/StudioArtworkEdit.jsx index 1ffb7360..62dbdc5b 100644 --- a/resources/js/Pages/Studio/StudioArtworkEdit.jsx +++ b/resources/js/Pages/Studio/StudioArtworkEdit.jsx @@ -63,6 +63,16 @@ export default function StudioArtworkEdit() { width: artwork?.width || 0, height: artwork?.height || 0, }) + const [versionCount, setVersionCount] = useState(artwork?.version_count ?? 1) + const [requiresReapproval, setRequiresReapproval] = useState(artwork?.requires_reapproval ?? false) + const [changeNote, setChangeNote] = useState('') + const [showChangeNote, setShowChangeNote] = useState(false) + + // Version history modal state + const [showHistory, setShowHistory] = useState(false) + const [historyData, setHistoryData] = useState(null) + const [historyLoading, setHistoryLoading] = useState(false) + const [restoring, setRestoring] = useState(null) // version id being restored // --- Tag search --- const searchTags = useCallback(async (q) => { @@ -158,6 +168,7 @@ export default function StudioArtworkEdit() { try { const fd = new FormData() fd.append('file', file) + if (changeNote.trim()) fd.append('change_note', changeNote.trim()) const res = await fetch(`/api/studio/artworks/${artwork.id}/replace-file`, { method: 'POST', headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, @@ -168,8 +179,12 @@ export default function StudioArtworkEdit() { if (res.ok && data.thumb_url) { setThumbUrl(data.thumb_url) setFileMeta({ name: file.name, size: file.size, width: data.width || 0, height: data.height || 0 }) + if (data.version_number) setVersionCount(data.version_number) + if (typeof data.requires_reapproval !== 'undefined') setRequiresReapproval(data.requires_reapproval) + setChangeNote('') + setShowChangeNote(false) } else { - console.error('File replace failed:', data) + alert(data.error || 'File replacement failed.') } } catch (err) { console.error('File replace failed:', err) @@ -179,6 +194,47 @@ export default function StudioArtworkEdit() { } } + const loadVersionHistory = async () => { + setHistoryLoading(true) + setShowHistory(true) + try { + const res = await fetch(`/api/studio/artworks/${artwork.id}/versions`, { + headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, + credentials: 'same-origin', + }) + const data = await res.json() + setHistoryData(data) + } catch (err) { + console.error('Failed to load version history:', err) + } finally { + setHistoryLoading(false) + } + } + + const handleRestoreVersion = async (versionId) => { + if (!window.confirm('Restore this version? It will be cloned as the new current version.')) return + setRestoring(versionId) + try { + const res = await fetch(`/api/studio/artworks/${artwork.id}/restore/${versionId}`, { + method: 'POST', + headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, + credentials: 'same-origin', + }) + const data = await res.json() + if (res.ok && data.success) { + alert(data.message) + setVersionCount((n) => n + 1) + setShowHistory(false) + } else { + alert(data.error || 'Restore failed.') + } + } catch (err) { + console.error('Restore failed:', err) + } finally { + setRestoring(null) + } + } + // --- Render --- return ( @@ -193,7 +249,28 @@ export default function StudioArtworkEdit() {
{/* ── Uploaded Asset ── */}
-

Uploaded Asset

+
+

Uploaded Asset

+
+ {requiresReapproval && ( + + Under Review + + )} + + v{versionCount} + + {versionCount > 1 && ( + + )} +
+
{thumbUrl ? ( {title} @@ -208,16 +285,41 @@ export default function StudioArtworkEdit() { {fileMeta.width > 0 && (

{fileMeta.width} × {fileMeta.height} px

)} + {showChangeNote && ( +