, files: array>} */ public function captureArtworkSnapshot(Artwork $artwork): array { return [ 'artwork' => [ 'file_name' => (string) ($artwork->file_name ?? ''), 'file_path' => (string) ($artwork->file_path ?? ''), 'hash' => (string) ($artwork->hash ?? ''), 'file_ext' => (string) ($artwork->file_ext ?? ''), 'thumb_ext' => (string) ($artwork->thumb_ext ?? ''), 'file_size' => (int) ($artwork->file_size ?? 0), 'mime_type' => (string) ($artwork->mime_type ?? 'application/octet-stream'), 'width' => (int) ($artwork->width ?? 0), 'height' => (int) ($artwork->height ?? 0), ], 'files' => DB::table('artwork_files') ->where('artwork_id', $artwork->id) ->orderBy('variant') ->get(['variant', 'path', 'mime', 'size']) ->map(fn ($row): array => [ 'variant' => (string) ($row->variant ?? ''), 'path' => (string) ($row->path ?? ''), 'mime' => (string) ($row->mime ?? 'application/octet-stream'), 'size' => (int) ($row->size ?? 0), ]) ->values() ->all(), ]; } /** * Create a new immutable revision from a fully materialized artwork snapshot. * * @param array $snapshot * @param array|null $previousSnapshot */ public function createVersionFromSnapshot( Artwork $artwork, array $snapshot, int $userId, ?string $changeNote = null, ?array $previousSnapshot = null, ): ArtworkVersion { $normalizedSnapshot = $this->normalizeSnapshot($snapshot); $previous = $this->normalizeSnapshot($previousSnapshot ?? $this->captureArtworkSnapshot($artwork)); $this->rateLimitCheck($userId, $artwork->id); return DB::transaction(function () use ($artwork, $normalizedSnapshot, $previous, $userId, $changeNote): ArtworkVersion { $this->ensureBaselineVersion($artwork, $previous, $userId); $nextNumber = max((int) $artwork->versions()->max('version_number'), 0) + 1; $meta = $this->snapshotArtworkMeta($normalizedSnapshot); $artwork->versions()->update(['is_current' => false]); $version = ArtworkVersion::create([ 'artwork_id' => $artwork->id, 'version_number' => $nextNumber, 'file_path' => $meta['file_path'], 'file_hash' => $meta['hash'], 'width' => $meta['width'], 'height' => $meta['height'], 'file_size' => $meta['file_size'], 'change_note' => $changeNote, 'snapshot_json' => $normalizedSnapshot, 'is_current' => true, ]); $needsReapproval = $this->shouldRequireReapprovalFromSnapshots($previous, $normalizedSnapshot); $artwork->update([ 'current_version_id' => $version->id, 'version_count' => $nextNumber, 'version_updated_at' => now(), 'requires_reapproval' => $needsReapproval, ]); $this->applyRankingProtection($artwork); ArtworkVersionEvent::create([ 'artwork_id' => $artwork->id, 'user_id' => $userId, 'action' => 'create_version', 'version_id' => $version->id, ]); $this->incrementRateLimitCounters($userId, $artwork->id); return $version; }); } /** * Apply a stored snapshot back onto the artwork row and artwork_files table. * * @param array $snapshot */ public function applySnapshot(Artwork $artwork, array $snapshot): void { $normalizedSnapshot = $this->normalizeSnapshot($snapshot); $meta = $this->snapshotArtworkMeta($normalizedSnapshot); DB::transaction(function () use ($artwork, $normalizedSnapshot, $meta): void { DB::table('artwork_files') ->where('artwork_id', $artwork->id) ->delete(); $rows = collect($normalizedSnapshot['files'] ?? []) ->filter(fn (array $row): bool => ($row['variant'] ?? '') !== '' && ($row['path'] ?? '') !== '') ->map(fn (array $row): array => [ 'artwork_id' => $artwork->id, 'variant' => (string) $row['variant'], 'path' => (string) $row['path'], 'mime' => (string) ($row['mime'] ?? 'application/octet-stream'), 'size' => (int) ($row['size'] ?? 0), ]) ->values() ->all(); if ($rows !== []) { DB::table('artwork_files')->insert($rows); } $artwork->update([ 'file_name' => $meta['file_name'], 'file_path' => $meta['file_path'], 'hash' => $meta['hash'], 'file_ext' => $meta['file_ext'], 'thumb_ext' => $meta['thumb_ext'], 'file_size' => $meta['file_size'], 'mime_type' => $meta['mime_type'], 'width' => $meta['width'], 'height' => $meta['height'], ]); }); } /** * Create a new version for an artwork after a file replacement. * * This is the primary entry-point called by the controller. * * @param Artwork $artwork The artwork being updated. * @param string $filePath Relative path stored for this version. * @param string $fileHash SHA-256 hex hash of the new file. * @param int $width New file width in pixels. * @param int $height New file height in pixels. * @param int $fileSize New file size in bytes. * @param int $userId ID of the acting user (for audit log). * @param string|null $changeNote Optional user-supplied change note. * * @throws TooManyRequestsHttpException When rate limit is exceeded. * @throws \RuntimeException When the hash is identical to the current file. */ public function createNewVersion( Artwork $artwork, string $filePath, string $fileHash, int $width, int $height, int $fileSize, int $userId, ?string $changeNote = null, ): ArtworkVersion { $snapshot = [ 'artwork' => [ 'file_name' => (string) ($artwork->file_name ?? 'artwork'), 'file_path' => $filePath, 'hash' => $fileHash, 'file_ext' => (string) ($artwork->file_ext ?? ''), 'thumb_ext' => (string) ($artwork->thumb_ext ?? ''), 'file_size' => $fileSize, 'mime_type' => (string) ($artwork->mime_type ?? 'application/octet-stream'), 'width' => $width, 'height' => $height, ], 'files' => [], ]; return $this->createVersionFromSnapshot($artwork, $snapshot, $userId, $changeNote); } /** * 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 { $previousSnapshot = $this->captureArtworkSnapshot($artwork); $snapshot = is_array($version->snapshot_json) ? $this->normalizeSnapshot($version->snapshot_json) : $this->normalizeSnapshot([ 'artwork' => array_merge($previousSnapshot['artwork'] ?? [], [ 'file_name' => (string) ($artwork->file_name ?? 'artwork'), 'file_path' => $version->file_path, 'hash' => $version->file_hash, 'file_ext' => (string) ($artwork->file_ext ?? ''), 'thumb_ext' => (string) ($artwork->thumb_ext ?? ''), 'file_size' => (int) $version->file_size, 'mime_type' => (string) ($artwork->mime_type ?? 'application/octet-stream'), 'width' => (int) $version->width, 'height' => (int) $version->height, ]), 'files' => $previousSnapshot['files'] ?? [], ]); $this->applySnapshot($artwork, $snapshot); $artwork->refresh(); return $this->createVersionFromSnapshot( $artwork, $snapshot, $userId, "Restored from version {$version->version_number}", $previousSnapshot, ); } /** * 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; } /** * @param array $previousSnapshot * @param array $newSnapshot */ public function shouldRequireReapprovalFromSnapshots(array $previousSnapshot, array $newSnapshot): bool { $previous = $this->snapshotArtworkMeta($previousSnapshot); $next = $this->snapshotArtworkMeta($newSnapshot); if (($previous['width'] ?? 0) <= 0 || ($previous['height'] ?? 0) <= 0) { return false; } $widthChange = abs($next['width'] - $previous['width']) / max($previous['width'], 1); $heightChange = abs($next['height'] - $previous['height']) / max($previous['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); } } /** * @param array|null $snapshot */ private function ensureBaselineVersion(Artwork $artwork, ?array $snapshot, int $userId): ?ArtworkVersion { if ($artwork->versions()->exists()) { return null; } $normalizedSnapshot = $this->normalizeSnapshot($snapshot ?? $this->captureArtworkSnapshot($artwork)); $meta = $this->snapshotArtworkMeta($normalizedSnapshot); $baselineNumber = max(1, (int) ($artwork->version_count ?? 1)); $version = ArtworkVersion::create([ 'artwork_id' => $artwork->id, 'version_number' => $baselineNumber, 'file_path' => $meta['file_path'], 'file_hash' => $meta['hash'], 'width' => $meta['width'], 'height' => $meta['height'], 'file_size' => $meta['file_size'], 'change_note' => 'Baseline snapshot', 'snapshot_json' => $normalizedSnapshot, 'is_current' => true, ]); $artwork->update([ 'current_version_id' => $version->id, 'version_count' => $baselineNumber, 'version_updated_at' => now(), ]); ArtworkVersionEvent::create([ 'artwork_id' => $artwork->id, 'user_id' => $userId, 'action' => 'baseline_snapshot', 'version_id' => $version->id, ]); return $version; } /** * @param array $snapshot * @return array{file_name: string, file_path: string, hash: string, file_ext: string, thumb_ext: string, file_size: int, mime_type: string, width: int, height: int} */ private function snapshotArtworkMeta(array $snapshot): array { $normalized = $this->normalizeSnapshot($snapshot); $artwork = $normalized['artwork']; return [ 'file_name' => (string) ($artwork['file_name'] ?? 'artwork'), 'file_path' => (string) ($artwork['file_path'] ?? ''), 'hash' => (string) ($artwork['hash'] ?? ''), 'file_ext' => (string) ($artwork['file_ext'] ?? ''), 'thumb_ext' => (string) ($artwork['thumb_ext'] ?? ''), 'file_size' => (int) ($artwork['file_size'] ?? 0), 'mime_type' => (string) ($artwork['mime_type'] ?? 'application/octet-stream'), 'width' => max(0, (int) ($artwork['width'] ?? 0)), 'height' => max(0, (int) ($artwork['height'] ?? 0)), ]; } /** * @param array|null $snapshot * @return array{artwork: array, files: array>} */ private function normalizeSnapshot(?array $snapshot): array { $artwork = is_array($snapshot['artwork'] ?? null) ? $snapshot['artwork'] : []; $files = is_array($snapshot['files'] ?? null) ? $snapshot['files'] : []; return [ 'artwork' => [ 'file_name' => (string) ($artwork['file_name'] ?? 'artwork'), 'file_path' => (string) ($artwork['file_path'] ?? ''), 'hash' => (string) ($artwork['hash'] ?? ''), 'file_ext' => (string) ($artwork['file_ext'] ?? ''), 'thumb_ext' => (string) ($artwork['thumb_ext'] ?? ''), 'file_size' => (int) ($artwork['file_size'] ?? 0), 'mime_type' => (string) ($artwork['mime_type'] ?? 'application/octet-stream'), 'width' => max(0, (int) ($artwork['width'] ?? 0)), 'height' => max(0, (int) ($artwork['height'] ?? 0)), ], 'files' => collect($files) ->filter(fn ($file): bool => is_array($file)) ->map(fn (array $file): array => [ 'variant' => (string) ($file['variant'] ?? ''), 'path' => (string) ($file['path'] ?? ''), 'mime' => (string) ($file['mime'] ?? 'application/octet-stream'), 'size' => (int) ($file['size'] ?? 0), ]) ->filter(fn (array $file): bool => $file['variant'] !== '' && $file['path'] !== '') ->values() ->all(), ]; } }