نسخة كاملة من مستودع سيرو عند ecfe7568 لتكون أساس تطبيق «انطلق». نُسخ المتعقَّب في git فقط (12,509 ملفاً / 302 م.ب) بـ git archive، لا `cp -r` — فاستُثنيت تلقائياً مخلفات البناء (build · node_modules · .dart_tool · .gradle · Pods ≈ 10.7 غ.ب) وكل ما يستثنيه .gitignore. هذا الكوميت **بلا أي تعديل عمداً** حتى يكون كل ما يليه فرقاً مقروءاً مقابل سيرو الأصلي. سيرو نفسه لم يُمسّ. ⚠️ لا يبني بعد: `.env` و`lib/env/env.g.dart` غير متعقَّبين في سيرو (وهذا صحيح — أسرار لكل مستأجر). كل تطبيق فلاتر هنا يحتاج .env خاصاً بانطلق ثم توليد env.g.dart عبر build_runner. لا تُنسخ أسرار سيرو. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
135 lines
5.3 KiB
PHP
135 lines
5.3 KiB
PHP
<?php
|
|
// ============================================================
|
|
// core/Auth/RateLimiter.php
|
|
// Sliding Window Rate Limiting باستخدام Redis
|
|
// ============================================================
|
|
|
|
class RateLimiter
|
|
{
|
|
private ?Redis $redis;
|
|
|
|
// حدود مختلفة لكل نوع endpoint
|
|
private const LIMITS = [
|
|
'login' => ['requests' => 5, 'window' => 60], // 5 محاولات / دقيقة
|
|
'tester_login' => ['requests' => 3, 'window' => 60], // 3 محاولات / دقيقة
|
|
'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
|
|
{
|
|
$ip = $_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);
|
|
}
|
|
}
|