storagePath = $storagePath ?? (defined('RATE_LIMIT_STORAGE_PATH') ? RATE_LIMIT_STORAGE_PATH : __DIR__ . '/../rate_limits'); // Only check directory once per process (static flag) if (!self::$dirVerified) { if (!is_dir($this->storagePath)) { mkdir($this->storagePath, 0755, true); } self::$dirVerified = true; } // Cleanup old files very rarely (0.1% of requests instead of 1%) if (rand(1, 1000) === 1) { $this->cleanup(); } } /** * Check if the request should be rate limited * * Uses exclusive file locking for the entire read-check-increment-write cycle * to prevent race conditions under concurrent requests. * * @param string $endpoint Endpoint identifier (e.g., 'login', 'session') * @param int|null $limit Custom limit for this endpoint (requests per minute) * @return bool True if request is allowed, false if rate limited */ /** @var bool Fail-closed: blokuj request pri chybe FS (pro kriticke endpointy) */ private bool $failClosed = false; public function setFailClosed(bool $failClosed = true): self { $this->failClosed = $failClosed; return $this; } public function check(string $endpoint, ?int $limit = null): bool { $limit = $limit ?? $this->defaultLimit; $ip = $this->getClientIp(); $key = $this->getKey($ip, $endpoint); $file = $this->storagePath . '/' . $key . '.json'; $now = time(); // Open file with exclusive lock for atomic read-check-increment-write $fp = @fopen($file, 'c+'); if (!$fp) { return !$this->failClosed; } if (!flock($fp, LOCK_EX)) { fclose($fp); return !$this->failClosed; } // Read current data under lock $content = stream_get_contents($fp); $data = $content ? json_decode($content, true) : null; if (is_array($data) && $data['window_start'] > ($now - $this->windowSeconds)) { // Same window - check count if ($data['count'] >= $limit) { flock($fp, LOCK_UN); fclose($fp); return false; // Rate limited } $data['count']++; } else { // New window - reset counter $data = ['window_start' => $now, 'count' => 1]; } // Write updated data ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($data)); fflush($fp); flock($fp, LOCK_UN); fclose($fp); return true; } /** * Enforce rate limit and return 429 response if exceeded * * @param string $endpoint Endpoint identifier * @param int|null $limit Custom limit for this endpoint */ public function enforce(string $endpoint, ?int $limit = null): void { if (!$this->check($endpoint, $limit)) { $this->sendRateLimitResponse(); } } /** * Send 429 Too Many Requests response and exit */ private function sendRateLimitResponse(): void { http_response_code(429); header('Content-Type: application/json; charset=utf-8'); header('Retry-After: ' . $this->windowSeconds); echo json_encode([ 'success' => false, 'error' => 'Prilis mnoho pozadavku. Zkuste to prosim pozdeji.', 'retry_after' => $this->windowSeconds, ], JSON_UNESCAPED_UNICODE); exit(); } /** * Get client IP address * * @return string IP address */ private function getClientIp(): string { // Use the global helper if available if (function_exists('getClientIp')) { return getClientIp(); } // Fallback: use only REMOTE_ADDR (cannot be spoofed) return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } /** * Generate storage key for IP and endpoint * * @param string $ip Client IP * @param string $endpoint Endpoint identifier * @return string Storage key (filename-safe) */ private function getKey(string $ip, string $endpoint): string { // Create a safe filename from IP and endpoint return md5($ip . ':' . $endpoint); } /** * Cleanup expired rate limit files * * Removes files older than the time window to prevent disk space issues */ private function cleanup(): void { if (!is_dir($this->storagePath)) { return; } $files = glob($this->storagePath . '/*.json'); $expireTime = time() - ($this->windowSeconds * 2); // Keep for 2x window to be safe foreach ($files as $file) { if (filemtime($file) < $expireTime) { @unlink($file); } } } /** * Clear all rate limit data (useful for testing) */ public function clearAll(): void { if (!is_dir($this->storagePath)) { return; } $files = glob($this->storagePath . '/*.json'); foreach ($files as $file) { @unlink($file); } } }