['requests' => 5, 'window' => 60], // 5 محاولات / دقيقة 'tester_login' => ['requests' => 30, 'window' => 60], // 30 محاولة / دقيقة (مراجعو المتاجر يكرّرون الدخول بسرعة) 'otp' => ['requests' => 3, 'window' => 300], // 3 محاولات / 5 دقائق 'register' => ['requests' => 3, 'window' => 3600], // 3 محاولات / ساعة 'api' => ['requests' => 180, 'window' => 60], // 180 طلب / دقيقة (الإنتاج الرسمى) 'ride' => ['requests' => 60, 'window' => 60], // 60 طلب / دقيقة (الإنتاج الرسمي) 'upload' => ['requests' => 10, 'window' => 300], // 10 رفع / 5 دقائق 'complaint' => ['requests' => 5, 'window' => 600], // 5 شكاوى / 10 دقائق (كل شكوى تستدعي Gemini + واتساب) ]; public function __construct(?Redis $redis) { $this->redis = $redis; } // ── فحص الحد ───────────────────────────────────────────── // $identifier: IP:userId أو IP فقط // $type: login | otp | api | ride | upload public function check(string $identifier, string $type = 'api'): bool { if (getenv('DISABLE_RATE_LIMITER') === 'true' || ($_ENV['DISABLE_RATE_LIMITER'] ?? '') === 'true') { return true; } if (!$this->redis) { // HIGH-01 FIX: fallback مع ملف بدلاً من تمرير كل الطلبات return $this->fileBasedCheck($identifier, $type); } $limit = self::LIMITS[$type] ?? self::LIMITS['api']; $window = $limit['window']; $max = $limit['requests']; $key = "rate:{$type}:{$identifier}"; $current = $this->redis->incr($key); if ($current === 1) { $this->redis->expire($key, $window); } return $current <= $max; } // ── تطبيق الحد وإيقاف الطلب إن تجاوز ───────────────────── public function enforce(string $identifier, string $type = 'api'): void { if (getenv('DISABLE_RATE_LIMITER') === 'true' || ($_ENV['DISABLE_RATE_LIMITER'] ?? '') === 'true') { return; } if (!$this->check($identifier, $type)) { $limit = self::LIMITS[$type] ?? self::LIMITS['api']; $window = $limit['window']; error_log("[RATE_LIMIT] Blocked: $identifier | type: $type"); http_response_code(429); header("Retry-After: $window"); echo json_encode([ 'error' => 'Too many requests. Please slow down.', 'retry_after' => $window, ]); exit; } } // ── بناء معرّف المستخدم ──────────────────────────────────── public static function identifier(?string $userId = null): string { // كان REMOTE_ADDR وحده، وهو خلف دوكر دائماً عنوان الوسيط نفسه // (172.24.0.1) — فيتشارك كل المستخدمين دلواً واحداً، ومحاولات دخول // فاشلة من عميل واحد تحجب الجميع بـ 429 (ظهر ذلك في سجل الإنتاج). // clientIp() تستخرج العميل الحقيقي من X-Forwarded-For ولا تثق به // إلا إذا كان REMOTE_ADDR وسيطاً موثوقاً. $ip = function_exists('clientIp') ? clientIp() : ($_SERVER['REMOTE_ADDR'] ?? 'unknown'); return $userId ? "{$ip}:{$userId}" : $ip; } // ── إعادة تعيين عداد (مثلاً بعد تسجيل دخول ناجح) ─────────── public function reset(string $identifier, string $type = 'login'): void { if ($this->redis) { $this->redis->del("rate:{$type}:{$identifier}"); } else { // HIGH-01: مسح ملف الفل باك عند إعادة التعيين $key = self::sanitizeKey("rate:{$type}:{$identifier}"); $tmpFile = sys_get_temp_dir() . "/rate_{$key}.json"; if (file_exists($tmpFile)) { @unlink($tmpFile); } } } // ── Fallback باستخدام ملفات مؤقتة عند تعطل Redis ─────────── private function fileBasedCheck(string $identifier, string $type): bool { $limit = self::LIMITS[$type] ?? self::LIMITS['api']; $window = $limit['window']; $max = $limit['requests']; $key = self::sanitizeKey("rate:{$type}:{$identifier}"); $tmpFile = sys_get_temp_dir() . "/rate_{$key}.json"; $now = time(); $data = []; if (file_exists($tmpFile)) { $data = json_decode(file_get_contents($tmpFile), true) ?: []; } // تنظيف النوافذ القديمة $data = array_filter($data, fn($ts) => $ts > ($now - $window)); if (count($data) >= $max) { error_log("[RATE_LIMIT_FB] File-based block: $identifier | type: $type"); return false; } $data[] = $now; file_put_contents($tmpFile, json_encode($data)); return true; } private static function sanitizeKey(string $key): string { return preg_replace('/[^a-zA-Z0-9_\-:]/', '_', $key); } }