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); } } }