maxRequests = $maxRequests; $this->timeWindow = $timeWindow; $this->cacheDir = $cacheDir ?? dirname(__DIR__) . '/storage/cache/rate_limit'; // Create cache directory if it doesn't exist if (!is_dir($this->cacheDir)) { @mkdir($this->cacheDir, 0755, true); } } /** * Get client identifier (IP address) * @return string Client identifier */ private function getClientIdentifier(): string { // Use REMOTE_ADDR only — HTTP_X_FORWARDED_FOR and HTTP_CLIENT_IP // are fully attacker-controlled request headers and must never be // trusted for rate-limiting purposes (an attacker can rotate them // freely to bypass the limiter). Nginx-level rate limiting also // uses $binary_remote_addr for the same reason. If this app is // ever placed behind a trusted reverse-proxy, IP extraction should // be handled at the nginx level, not here. return $_SERVER['REMOTE_ADDR'] ?? 'unknown'; } /** * Get cache file path for a client * @param string $identifier Client identifier * @return string File path */ private function getCacheFile($identifier) { return $this->cacheDir . '/' . md5($identifier) . '.json'; } /** * Check if client has exceeded rate limit * @return bool True if allowed, false if rate limit exceeded */ public function check() { $identifier = $this->getClientIdentifier(); $file = $this->getCacheFile($identifier); // Load existing request timestamps $data = []; if (file_exists($file)) { $content = file_get_contents($file); $data = json_decode($content, true) ?? []; } // Clean old entries outside time window $now = time(); $data = array_filter($data, function($timestamp) use ($now) { return ($now - $timestamp) < $this->timeWindow; }); // Check if limit exceeded if (count($data) >= $this->maxRequests) { return false; } // Add new request timestamp $data[] = $now; // Save updated data (silently skip if directory is not writable) if (is_dir($this->cacheDir) && is_writable($this->cacheDir)) { file_put_contents($file, json_encode($data)); } return true; } /** * Get remaining requests for current client * @return int Number of requests remaining */ public function getRemaining() { $identifier = $this->getClientIdentifier(); $file = $this->getCacheFile($identifier); if (!file_exists($file)) { return $this->maxRequests; } $content = file_get_contents($file); $data = json_decode($content, true) ?? []; // Clean old entries $now = time(); $data = array_filter($data, function($timestamp) use ($now) { return ($now - $timestamp) < $this->timeWindow; }); return max(0, $this->maxRequests - count($data)); } /** * Get time until rate limit resets * @return int Seconds until reset */ public function getResetTime() { $identifier = $this->getClientIdentifier(); $file = $this->getCacheFile($identifier); if (!file_exists($file)) { return 0; } $content = file_get_contents($file); $data = json_decode($content, true) ?? []; if (empty($data)) { return 0; } // Find oldest timestamp $oldest = min($data); $resetTime = $oldest + $this->timeWindow - time(); return max(0, $resetTime); } /** * Clean up old cache files (run periodically) * Removes files that haven't been modified in 24 hours */ public function cleanup() { $files = glob($this->cacheDir . '/*.json'); $cutoff = time() - 86400; // 24 hours foreach ($files as $file) { if (filemtime($file) < $cutoff) { unlink($file); } } } /** * Send rate limit headers * Provides information about rate limits to clients */ public function sendHeaders() { header('X-RateLimit-Limit: ' . $this->maxRequests); header('X-RateLimit-Remaining: ' . $this->getRemaining()); header('X-RateLimit-Reset: ' . (time() + $this->getResetTime())); } }