maxAttempts = $maxAttempts; $this->decaySeconds = $decaySeconds; } public function handle(Request $request, Response $response): void { $ip = $this->getClientIp(); $key = 'rate_' . md5($ip . '_' . $request->getPath()); $storageDir = APP_ROOT . '/storage/rate_limits'; if (!is_dir($storageDir)) { mkdir($storageDir, 0750, true); } $filePath = $storageDir . '/' . $key . '.json'; $data = ['count' => 0, 'expires_at' => time() + $this->decaySeconds]; if (file_exists($filePath)) { $raw = json_decode(file_get_contents($filePath), true); if ($raw && isset($raw['expires_at']) && $raw['expires_at'] > time()) { // Window still active — use existing data $data = $raw; } // If window expired, fall through and reset (overwrite with fresh data below) } $data['count']++; if ($data['count'] > $this->maxAttempts) { $retryAfter = max(0, $data['expires_at'] - time()); $response->setHeader('Retry-After', (string)$retryAfter); $response->json([ 'error' => 'Too Many Requests', 'message' => "You have exceeded the maximum number of {$this->maxAttempts} attempts. Please try again in {$retryAfter} seconds." ], 429); return; } // Persist the updated counter file_put_contents($filePath, json_encode($data), LOCK_EX); } /** * Get real client IP, accounting for proxies */ private function getClientIp(): string { $headers = [ 'HTTP_CF_CONNECTING_IP', // Cloudflare 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' ]; foreach ($headers as $header) { if (!empty($_SERVER[$header])) { // X-Forwarded-For can be a comma-separated list; take first $ip = trim(explode(',', $_SERVER[$header])[0]); if (filter_var($ip, FILTER_VALIDATE_IP)) { return $ip; } } } return '0.0.0.0'; } }