feat: استيراد كود سيرو إلى تريبز (سيرو @ecfe7568) — بلا تعديل
قرار المالك 2026-07-27: باك إند سيرو PHP هو المعتمد، وتطبيقاته المجرّبة ميدانياً تحل محل إعادة البناء المؤرشفة. سيرو نفسه لم يُمسّ. الخريطة: backend · payment_server · loction_server · ride_server · passenger_server · docker · dashboard · stress_test → الجذر siro_rider → apps/rider siro_driver → apps/driver siro_admin → dashboards/admin siro_service → dashboards/service android_bot → apps/android_bot socialBot → apps/socialBot نُسخ المتعقَّب في git سيرو فقط عبر `git archive` (3,198 ملفاً / ~169 م.ب) لا `cp -r` — فاستُثنيت مخلفات البناء تلقائياً. بلا أي تعديل محتوى عمداً: كل ما يلي يصير فرقاً مقروءاً مقابل المصدر. لم يُستورد وسببه: siromove.com (الموقع التسويقي يبقى marketing/ في تريبز، سيرو فيه 8 ملفات) · docs و planning (تريبز له docs/ الخاص) · deploy.sh (ليس نشراً على سيرفر بل `git add . && git push origin --all` — فخّ في مستودع آخر) · transit_dashboard (بانتظار قرار مصير backend-transit و dashboards/transit-web). ⚠️ لا يبني بعد — ثلاثة نواقص متوقعة ومقصودة: 1. `.env` و `lib/env/env.g.dart` غير متعقَّبين في سيرو (أسرار لكل مستأجر): كل تطبيق فلاتر يحتاج .env خاصاً ثم توليد env.g.dart بـ build_runner. 2. إعدادات Firebase (9 ملفات google-services.json و GoogleService-Info.plist) يستبعدها .gitignore تريبز — ولكل مستأجر مشروع Firebase خاص أصلاً. 3. apps/driver في سيرو يشير إلى `../../Intaleq/packages/get` خارج المستودع → يجب ضمّ الحزم داخله أسوة بـ apps/rider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9909d9b4c1
commit
4d8414c96b
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/Auth/JwtService.php
|
||||
// JWT آمن: JTI + Blacklist في Redis + Refresh Token
|
||||
// ============================================================
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Firebase\JWT\ExpiredException;
|
||||
use Firebase\JWT\SignatureInvalidException;
|
||||
use Firebase\JWT\BeforeValidException;
|
||||
|
||||
class JwtService
|
||||
{
|
||||
private string $secretKey;
|
||||
private string $hmacSecret;
|
||||
private string $fpPepper;
|
||||
private ?Redis $redis;
|
||||
|
||||
private string $issuer;
|
||||
private const ACCESS_TTL = 3600; // 1 ساعة
|
||||
private const REFRESH_TTL = 2592000; // 30 يوم
|
||||
private const ALGO = 'HS256';
|
||||
|
||||
// Endpoints مسموح لها بتوكن نوع registration
|
||||
private const REGISTRATION_ENDPOINTS = [
|
||||
'loginFirstTime', 'loginFirstTimeDriver',
|
||||
'checkPhoneNumberISVerfied', 'checkPhoneNumberISVerfiedDriver', 'checkPhoneNumberISVerfiedPassenger',
|
||||
'otpmessage', 'signup', 'verifyEmail', 'verifyOtpMessage',
|
||||
'sendVerifyEmail', 'sendWhatsAppDriver', 'register_passenger',
|
||||
'sendWhatsOpt', 'verifyOtp', 'auth_proxy', 'addToken',
|
||||
'loginFromGoogle', 'loginUsingCredentialsWithoutGoogle',
|
||||
'loginFromGooglePassenger', 'loginUsingCredentialsWithoutGooglePassenger',
|
||||
'register', 'sendOtpMessageDriver', 'getTokensPassenger',
|
||||
'send_otp', 'verify_otp', 'errorApp', 'register_driver',
|
||||
'uploadImage', 'uploadDriverDocs', 'register_driver_and_car',
|
||||
];
|
||||
|
||||
public function __construct(?Redis $redis = null)
|
||||
{
|
||||
// ✅ FIX C-02: استخدام getenv بدلاً من file_get_contents الثابت
|
||||
$keyPath = getenv('JWT_SECRET_KEY_PATH');
|
||||
if ($keyPath && file_exists($keyPath)) {
|
||||
$this->secretKey = trim(file_get_contents($keyPath));
|
||||
} else {
|
||||
$this->secretKey = getenv('JWT_SECRET_KEY') ?: '';
|
||||
}
|
||||
|
||||
$this->hmacSecret = getenv('SECRET_KEY_HMAC') ?: '';
|
||||
$this->fpPepper = getenv('FP_PEPPER') ?: '';
|
||||
$this->issuer = (string)(getenv('APP_ISSUER') ?: '');
|
||||
$this->redis = $redis;
|
||||
}
|
||||
|
||||
|
||||
// ── توليد Access Token ──────────────────────────────────
|
||||
public function generateAccessToken(
|
||||
int|string $userId,
|
||||
string $role,
|
||||
string $audience,
|
||||
?string $fingerprint = null
|
||||
): string {
|
||||
$jti = bin2hex(random_bytes(16));
|
||||
|
||||
$ttl = 3600;
|
||||
if ($role === 'driver') {
|
||||
$ttl = 14400;
|
||||
} elseif ($role === 'passenger') {
|
||||
$ttl = 3600;
|
||||
} elseif ($role === 'service') {
|
||||
$ttl = 14400; // 4 hours as requested
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'iss' => $this->issuer,
|
||||
'aud' => $audience,
|
||||
'user_id' => $userId,
|
||||
'role' => $role,
|
||||
'token_type' => 'access',
|
||||
'jti' => $jti,
|
||||
'iat' => time(),
|
||||
'exp' => time() + $ttl,
|
||||
];
|
||||
|
||||
if ($fingerprint && $this->fpPepper) {
|
||||
$payload['fingerPrint'] = hash('sha256', $fingerprint . $this->fpPepper);
|
||||
}
|
||||
|
||||
$token = JWT::encode($payload, $this->secretKey, self::ALGO);
|
||||
|
||||
// تخزين في Redis لضمان عدم التكرار وإمكانية الإلغاء
|
||||
if ($this->redis) {
|
||||
$this->redis->setex("active_jti:{$userId}", $ttl, $jti);
|
||||
$this->redis->setex("active_token:{$userId}:{$audience}", $ttl, $token);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
// ── فك تشفير التوكن للتحقق الداخلي ────────────────────────
|
||||
public function decodeToken(string $token): ?object
|
||||
{
|
||||
try {
|
||||
return JWT::decode($token, new Key($this->secretKey, self::ALGO));
|
||||
} catch (Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── توليد Refresh Token ─────────────────────────────────
|
||||
public function generateRefreshToken(int|string $userId): array
|
||||
{
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$exp = time() + self::REFRESH_TTL;
|
||||
|
||||
// تخزين في Redis
|
||||
if ($this->redis) {
|
||||
$this->redis->setex(
|
||||
"refresh:{$userId}:{$token}",
|
||||
self::REFRESH_TTL,
|
||||
json_encode(['user_id' => $userId, 'created_at' => time()])
|
||||
);
|
||||
}
|
||||
|
||||
return ['token' => $token, 'expires_at' => $exp];
|
||||
}
|
||||
|
||||
// ── التحقق الكامل من التوكن ────────────────────────────
|
||||
public function authenticate(): object
|
||||
{
|
||||
// 1. استخراج التوكن
|
||||
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
$token = null;
|
||||
if (preg_match('/Bearer\s(\S+)/', $authHeader, $m)) {
|
||||
$token = $m[1];
|
||||
}
|
||||
|
||||
if (!$token) {
|
||||
self::abort(401, 'Authorization token required');
|
||||
}
|
||||
|
||||
// 2. Decode
|
||||
try {
|
||||
$decoded = JWT::decode($token, new Key($this->secretKey, self::ALGO));
|
||||
} catch (ExpiredException $e) {
|
||||
self::abort(401, 'Token expired');
|
||||
} catch (SignatureInvalidException $e) {
|
||||
// ممنوع استخدام أي مفتاح آخر - مفتاح JWT واحد فقط
|
||||
self::abort(401, 'Invalid token signature');
|
||||
} catch (BeforeValidException $e) {
|
||||
self::abort(401, 'Token not yet valid');
|
||||
} catch (Exception $e) {
|
||||
self::abort(401, 'Invalid token');
|
||||
}
|
||||
|
||||
// 3. Issuer (Only check if configured)
|
||||
if (!empty($this->issuer) && ($decoded->iss ?? '') !== $this->issuer) {
|
||||
self::abort(401, 'Invalid token issuer: expected ' . $this->issuer . ' but got ' . ($decoded->iss ?? 'none'));
|
||||
}
|
||||
|
||||
// 3.1 App Signature Verification (Service Only)
|
||||
$role = $decoded->role ?? 'unknown';
|
||||
if ($role === 'service') {
|
||||
$appSignature = $_SERVER['HTTP_X_APP_SIGNATURE'] ?? null;
|
||||
if ($appSignature === null && function_exists('getallheaders')) {
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$appSignature = $headers['x-app-signature'] ?? null;
|
||||
}
|
||||
|
||||
// نقبل بصمة الـ Release أو الـ Debug
|
||||
$allowedSignatures = array_filter([
|
||||
getenv('APP_SIGNATURE_SERVICE_RELEASE'),
|
||||
getenv('APP_SIGNATURE_SERVICE_DEBUG'),
|
||||
getenv('APP_SIGNATURE_HASH') // Fallback
|
||||
]);
|
||||
|
||||
if (!empty($allowedSignatures)) {
|
||||
// تخطي التحقق إذا كانت البصمة فارغة (مثلاً في المحاكي حيث
|
||||
// getAppSignature غير متوفرة). الأمان الحقيقي من HMAC.
|
||||
if ($appSignature !== null && $appSignature !== '' && !in_array($appSignature, $allowedSignatures)) {
|
||||
error_log("[SECURITY_ERROR] App Signature Mismatch! Role: $role | Got: " . $appSignature . " | User: " . ($decoded->user_id ?? 'unknown'));
|
||||
self::abort(403, 'App integrity check failed. Please use the official app.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. User ID
|
||||
$userId = $decoded->user_id ?? $decoded->sub ?? null;
|
||||
if (!$userId) {
|
||||
self::abort(401, 'Invalid JWT payload');
|
||||
}
|
||||
|
||||
// 5. JTI Blacklist (تحقق من توكنات ملغاة)
|
||||
$jti = $decoded->jti ?? null;
|
||||
if ($jti && $this->redis) {
|
||||
if ($this->redis->exists("jwt:blacklist:$jti")) {
|
||||
self::abort(401, 'Token has been revoked');
|
||||
}
|
||||
}
|
||||
|
||||
// 6. token_type — قيّد registration endpoints
|
||||
$tokenType = $decoded->token_type ?? 'access';
|
||||
if ($tokenType === 'registration' || $tokenType === 'new') {
|
||||
$currentFile = basename($_SERVER['PHP_SELF'], '.php');
|
||||
$allowed = false;
|
||||
foreach (self::REGISTRATION_ENDPOINTS as $ep) {
|
||||
if (strcasecmp($currentFile, $ep) === 0) {
|
||||
$allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$allowed) {
|
||||
error_log("[SECURITY] Registration token blocked on: $currentFile | user: $userId");
|
||||
self::abort(403, 'Token not authorized for this action');
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Device Fingerprint (إلزامي للـ Access Tokens)
|
||||
if ($this->fpPepper && $tokenType === 'access' && $role !== 'tester') {
|
||||
$fpInToken = $decoded->fingerPrint ?? null;
|
||||
$fpHeader = $_SERVER['HTTP_X_DEVICE_FP'] ?? null;
|
||||
|
||||
// محاولة جلب الهيدر بطرق بديلة إذا لم يوجد في $_SERVER
|
||||
if ($fpHeader === null && function_exists('getallheaders')) {
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$fpHeader = $headers['x-device-fp'] ?? null;
|
||||
}
|
||||
|
||||
if ($fpInToken === null || $fpHeader === null) {
|
||||
$allHeaders = json_encode(getallheaders());
|
||||
error_log("[SECURITY] Fingerprint missing | user: $userId | fpInToken: " . ($fpInToken ?? 'NULL') . " | fpHeader: " . ($fpHeader ?? 'NULL') . " | Headers: $allHeaders");
|
||||
self::abort(403, 'Device verification required');
|
||||
}
|
||||
|
||||
$expected = hash('sha256', $fpHeader . $this->fpPepper);
|
||||
if (!hash_equals($expected, $fpInToken)) {
|
||||
error_log("[SECURITY] Device mismatch | user: $userId | IP: " . ($_SERVER['REMOTE_ADDR'] ?? '?'));
|
||||
self::abort(403, 'Device mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
// 8. HMAC Verification (Derived Secret for Service)
|
||||
$hmacHeader = $_SERVER['HTTP_X_HMAC_AUTH'] ?? null;
|
||||
if ($hmacHeader !== null) {
|
||||
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
|
||||
$nonce = $_SERVER['HTTP_X_NONCE'] ?? '';
|
||||
$body = file_get_contents('php://input') ?: '';
|
||||
|
||||
// Replay protection: مُفعّلة فقط عند العملاء الذين يرسلون
|
||||
// Timestamp + Nonce فعلياً (بعض تدفقات الـ wallet القديمة لا ترسلهما بعد)
|
||||
if ($timestamp !== '' && $nonce !== '') {
|
||||
if (abs(time() - (int)$timestamp) > 300) {
|
||||
error_log("[SECURITY] HMAC timestamp expired | User: $userId | TS: '$timestamp'");
|
||||
self::abort(403, 'Request expired');
|
||||
}
|
||||
if ($this->redis) {
|
||||
$nonceKey = "hmac_nonce:{$userId}:{$nonce}";
|
||||
if ($this->redis->exists($nonceKey)) {
|
||||
error_log("[SECURITY] HMAC nonce replay detected | User: $userId | Nonce: $nonce");
|
||||
self::abort(403, 'Replay detected');
|
||||
}
|
||||
$this->redis->setex($nonceKey, 300, '1');
|
||||
}
|
||||
}
|
||||
|
||||
// اشتقاق مفتاح الـ HMAC الخاص بهذا المستخدم
|
||||
$userSecret = hash_hmac('sha256', (string)$userId, $this->hmacSecret);
|
||||
|
||||
// المعادلة الموحدة: Body + Timestamp + Nonce
|
||||
$payloadToSign = $body . $timestamp . $nonce;
|
||||
$expectedHmac = hash_hmac('sha256', $payloadToSign, $userSecret);
|
||||
|
||||
if (!hash_equals($expectedHmac, $hmacHeader)) {
|
||||
$bodyLen = strlen($body);
|
||||
error_log("[SECURITY] HMAC mismatch | User: $userId | BodyLen: $bodyLen | TS: '$timestamp'");
|
||||
// ✅ FIX H-02: إزالة معلومات الـ Debug من الاستجابة
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Request verification failed']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
// ── إلغاء توكن (Logout / Password Change) ──────────────
|
||||
public function revokeToken(string $jti, int $remainingTTL = 900): void
|
||||
{
|
||||
if ($this->redis && $jti) {
|
||||
$this->redis->setex("jwt:blacklist:$jti", $remainingTTL + 60, '1');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal API Key — للـ get_connect.php ─────────────
|
||||
public static function validateInternalKey(): void
|
||||
{
|
||||
$keyPath = getenv('INTERNAL_SOCKET_KEY_PATH');
|
||||
$sent = $_SERVER['HTTP_X_INTERNAL_KEY'] ?? '';
|
||||
$expected = '';
|
||||
if ($keyPath && file_exists($keyPath)) {
|
||||
$expected = trim(file_get_contents($keyPath));
|
||||
}
|
||||
if (!$expected) {
|
||||
$expected = getenv('INTERNAL_SOCKET_KEY');
|
||||
}
|
||||
|
||||
if (!$expected || !hash_equals($expected, $sent)) {
|
||||
error_log('[SECURITY] Invalid internal key from: ' . ($_SERVER['REMOTE_ADDR'] ?? '?'));
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized internal request']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public function getFpPepper(): string
|
||||
{
|
||||
return $this->fpPepper;
|
||||
}
|
||||
|
||||
private static function abort(int $code, string $message)
|
||||
{
|
||||
error_log("[JWT_AUTH_FAILED] Code: $code | Message: $message | IP: " . ($_SERVER['REMOTE_ADDR'] ?? '?') . " | URI: " . ($_SERVER['REQUEST_URI'] ?? '?'));
|
||||
http_response_code($code);
|
||||
echo json_encode(['error' => $message]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/Database/Database.php — Lazy PDO Singleton (Refactored)
|
||||
// يدعم قواعد بيانات متعددة لكل منها Host/User/Pass مختلف
|
||||
// ============================================================
|
||||
|
||||
class Database
|
||||
{
|
||||
private static array $instances = [];
|
||||
|
||||
// خريطة الربط مع متغيرات البيئة (ENV)
|
||||
private static array $map = [
|
||||
'main' => [
|
||||
'name' => 'DB_PRIMARY_NAME_V2',
|
||||
'host' => 'DB_PRIMARY_HOST_V2',
|
||||
'user' => 'DB_PRIMARY_USER_V2',
|
||||
'pass' => 'DB_PRIMARY_PASS_V2',
|
||||
],
|
||||
'tracking' => [
|
||||
'name' => 'DB_TRACKING_NAME',
|
||||
'host' => 'DB_TRACKING_HOST',
|
||||
'user' => 'DB_TRACKING_USER',
|
||||
'pass' => 'DB_TRACKING_PASS',
|
||||
],
|
||||
'ride' => [
|
||||
'name' => 'DB_RIDE_NAME',
|
||||
'host' => 'DB_RIDE_HOST',
|
||||
'user' => 'DB_RIDE_USER',
|
||||
'pass' => 'DB_RIDE_PASS',
|
||||
],
|
||||
'transit' => [
|
||||
'name' => 'DB_TRANSIT_NAME',
|
||||
'host' => 'DB_TRANSIT_HOST',
|
||||
'user' => 'DB_TRANSIT_USER',
|
||||
'pass' => 'DB_TRANSIT_PASS',
|
||||
],
|
||||
];
|
||||
|
||||
public static function get(string $name = 'main'): PDO
|
||||
{
|
||||
if (!isset(self::$instances[$name])) {
|
||||
self::$instances[$name] = self::connect($name);
|
||||
}
|
||||
return self::$instances[$name];
|
||||
}
|
||||
|
||||
private static function connect(string $name): PDO
|
||||
{
|
||||
if (!isset(self::$map[$name])) {
|
||||
throw new InvalidArgumentException("Unknown database: $name");
|
||||
}
|
||||
|
||||
$cfg = self::$map[$name];
|
||||
|
||||
$dbname = getenv($cfg['name']);
|
||||
$host = getenv($cfg['host']) ?: 'localhost';
|
||||
$user = getenv($cfg['user']);
|
||||
$pass = getenv($cfg['pass']);
|
||||
|
||||
if (!$dbname || !$user) {
|
||||
error_log("[FATAL] Database config missing for: $name (Check ENV keys: {$cfg['name']}, {$cfg['user']})");
|
||||
throw new RuntimeException("Database configuration error.");
|
||||
}
|
||||
|
||||
$dsn = "mysql:host=$host;dbname=$dbname;charset=utf8mb4";
|
||||
$options = [
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_PERSISTENT => true,
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci",
|
||||
PDO::ATTR_TIMEOUT => 10,
|
||||
];
|
||||
|
||||
try {
|
||||
return new PDO($dsn, $user, $pass, $options);
|
||||
} catch (PDOException $e) {
|
||||
error_log("[DB] Connection failed ($name) at $host: " . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function __construct() {}
|
||||
private function __clone() {}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
/**
|
||||
* core/Security/BlindIndex.php
|
||||
*
|
||||
* فهرس أعمى للبحث فوق حقول مشفّرة.
|
||||
*
|
||||
* المشكلة: التشفير الآمن (AES-GCM) عشوائي — نفس النص ينتج تشفيراً مختلفاً في
|
||||
* كل مرة، فلا يمكن البحث بمقارنة النص المشفّر. والحل القديم (CBC بـ IV ثابت)
|
||||
* يجعل التشفير حتمياً فينجح البحث، لكنه يسرّب المساواة والبادئات المشتركة.
|
||||
*
|
||||
* الحل: نفصل التخزين عن البحث.
|
||||
* - التخزين: AES-GCM عشوائي (لا يسرّب شيئاً).
|
||||
* - البحث: عمود إضافي يحمل HMAC-SHA256 حتمياً للقيمة بعد تطبيعها.
|
||||
*
|
||||
* لماذا HMAC وليس sha256 عارياً؟ لأن مساحة أرقام الهواتف صغيرة (ملايين
|
||||
* قليلة) — جدول عكسي لكل الأرقام يُبنى في ثوانٍ. المفتاح السرّي (pepper)
|
||||
* المخزَّن في البيئة وحده يمنع ذلك، فمن يسرق قاعدة البيانات لا يملكه.
|
||||
*/
|
||||
|
||||
final class BlindIndex
|
||||
{
|
||||
private string $pepper;
|
||||
|
||||
public function __construct(?string $pepper = null)
|
||||
{
|
||||
$pepper = $pepper ?: (getenv('BLIND_INDEX_PEPPER') ?: '');
|
||||
if ($pepper === '') {
|
||||
throw new RuntimeException(
|
||||
'BLIND_INDEX_PEPPER is not set. Generate one with: openssl rand -hex 32'
|
||||
);
|
||||
}
|
||||
$this->pepper = $pepper;
|
||||
}
|
||||
|
||||
/**
|
||||
* يحسب الفهرس لقيمة داخل حقل محدد.
|
||||
*
|
||||
* $scope يشمل الجدول والحقل (مثل "driver.phone") عمداً: بدونه يكون فهرس
|
||||
* نفس الرقم متطابقاً في جدول السائقين والركاب، فيستطيع من يقرأ القاعدة
|
||||
* ربط الحسابات ببعضها دون فك أي تشفير.
|
||||
*/
|
||||
public function index(string $scope, ?string $value): ?string
|
||||
{
|
||||
$normalized = self::normalize($scope, $value);
|
||||
if ($normalized === null || $normalized === '') {
|
||||
return null;
|
||||
}
|
||||
return hash_hmac('sha256', $scope . ':' . $normalized, $this->pepper);
|
||||
}
|
||||
|
||||
/**
|
||||
* فهرس مبتور للبحث الجزئي (مثل الأسماء).
|
||||
*
|
||||
* البتر مقصود: يُنتج تطابقات كاذبة تُصفّى بعد فك التشفير، وهذه الضبابية
|
||||
* هي ما يمنع استخدام الفهرس نفسه في تحليل التكرارات.
|
||||
*/
|
||||
public function bucket(string $scope, ?string $value, int $length = 8): ?string
|
||||
{
|
||||
$full = $this->index($scope, $value);
|
||||
return $full === null ? null : substr($full, 0, $length);
|
||||
}
|
||||
|
||||
/**
|
||||
* التطبيع قبل الحساب — بدونه يُنتج 0791234567 و+962791234567 فهرسين
|
||||
* مختلفين ويفشل البحث.
|
||||
*/
|
||||
public static function normalize(string $scope, ?string $value): ?string
|
||||
{
|
||||
if ($value === null) return null;
|
||||
$value = trim($value);
|
||||
if ($value === '') return null;
|
||||
|
||||
if (str_contains($scope, 'phone')) {
|
||||
$digits = preg_replace('/\D+/', '', $value);
|
||||
// توحيد الصيغة المحلية والدولية على شكل واحد
|
||||
$digits = preg_replace('/^00/', '', $digits);
|
||||
if (str_starts_with($digits, '0')) {
|
||||
$cc = getenv('DEFAULT_COUNTRY_CODE') ?: '962';
|
||||
$digits = $cc . substr($digits, 1);
|
||||
}
|
||||
return $digits;
|
||||
}
|
||||
|
||||
if (str_contains($scope, 'email')) {
|
||||
return mb_strtolower($value, 'UTF-8');
|
||||
}
|
||||
|
||||
// الأسماء: توحيد حالة الأحرف والمسافات، وتوحيد أشكال الألف والياء
|
||||
// والتاء المربوطة العربية حتى لا يتوقف البحث على شكل الكتابة.
|
||||
$value = mb_strtolower($value, 'UTF-8');
|
||||
$value = preg_replace('/\s+/u', ' ', $value);
|
||||
$value = str_replace(
|
||||
['أ', 'إ', 'آ', 'ٱ', 'ى', 'ة', 'ؤ', 'ئ'],
|
||||
['ا', 'ا', 'ا', 'ا', 'ي', 'ه', 'و', 'ي'],
|
||||
$value
|
||||
);
|
||||
// إزالة التشكيل
|
||||
$value = preg_replace('/[\x{064B}-\x{0652}\x{0640}]/u', '', $value);
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/Security/EncryptionHelper.php
|
||||
// يدعم AES-256-GCM الجديد + AES-256-CBC القديم (توافقية)
|
||||
// ============================================================
|
||||
|
||||
class EncryptionHelper
|
||||
{
|
||||
private string $key;
|
||||
private string $cbcIv;
|
||||
private const ALGO_GCM = 'aes-256-gcm';
|
||||
private const ALGO_CBC = 'AES-256-CBC'; // للتوافقية
|
||||
private const IV_LEN_GCM = 12;
|
||||
private const TAG_LEN = 16;
|
||||
private const PREFIX_GCM = 'GCM:'; // للتمييز بين الجديد والقديم
|
||||
|
||||
/**
|
||||
* وضع الكتابة: 'cbc' (افتراضي) أو 'gcm'.
|
||||
*
|
||||
* القراءة غير متأثرة بهذا الوضع إطلاقاً — decryptData تتعرّف على الصيغتين
|
||||
* عبر البادئة، فالسجلات القديمة تبقى مقروءة بلا ترحيل، والرجوع عن التحويل
|
||||
* لا يُفقد أي سجل كُتب بـ GCM.
|
||||
*/
|
||||
private string $writeMode;
|
||||
|
||||
public function __construct(string $key, ?string $cbcIv = null, ?string $writeMode = null)
|
||||
{
|
||||
if (strlen($key) !== 32) {
|
||||
throw new InvalidArgumentException('Encryption key must be exactly 32 bytes.');
|
||||
}
|
||||
$this->key = $key;
|
||||
// IV القديم للتوافقية أثناء مرحلة المايغريشن
|
||||
$this->cbcIv = $cbcIv ?: getenv('initializationVector') ?: str_repeat('0', 16);
|
||||
|
||||
$mode = strtolower($writeMode ?: (getenv('ENCRYPTION_MODE') ?: 'cbc'));
|
||||
$this->writeMode = $mode === 'gcm' ? 'gcm' : 'cbc';
|
||||
}
|
||||
|
||||
public function writeMode(): string
|
||||
{
|
||||
return $this->writeMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* نقطة التشفير الموحّدة لكل التطبيق.
|
||||
*
|
||||
* حتى الآن كانت CBC بـ IV ثابت، أي حتمية: نفس النص ينتج نفس التشفير، وهو
|
||||
* ما كان يسمح بالبحث عبر مقارنة النص المشفّر، لكنه يسرّب المساواة
|
||||
* والبادئات المشتركة. مع ENCRYPTION_MODE=gcm يصبح التشفير عشوائياً
|
||||
* وموثَّقاً، ويتكفّل الفهرس الأعمى (BlindIndex) بالبحث.
|
||||
*/
|
||||
public function encryptData(string $plainText): string
|
||||
{
|
||||
if ($this->writeMode === 'gcm') {
|
||||
return $this->encryptDataGCM($plainText);
|
||||
}
|
||||
return $this->encryptDataCBC($plainText);
|
||||
}
|
||||
|
||||
// ─── تشفير نص باستخدام AES-256-CBC الحتمي (للتوافقية والرجوع) ──
|
||||
public function encryptDataCBC(string $plainText): string
|
||||
{
|
||||
$plainText = mb_convert_encoding($plainText, 'UTF-8');
|
||||
$padded = $this->addPadding($plainText);
|
||||
$encrypted = openssl_encrypt($padded, self::ALGO_CBC, $this->key, OPENSSL_RAW_DATA, $this->cbcIv);
|
||||
return base64_encode($encrypted);
|
||||
}
|
||||
|
||||
// ─── تشفير نص باستخدام AES-256-GCM العشوائي (عالي الأمان) ──
|
||||
public function encryptDataGCM(string $plainText): string
|
||||
{
|
||||
$plainText = mb_convert_encoding($plainText, 'UTF-8');
|
||||
$iv = random_bytes(self::IV_LEN_GCM);
|
||||
$tag = '';
|
||||
$encrypted = openssl_encrypt($plainText, self::ALGO_GCM, $this->key, OPENSSL_RAW_DATA, $iv, $tag, "", self::TAG_LEN);
|
||||
return self::PREFIX_GCM . base64_encode($iv . $tag . $encrypted);
|
||||
}
|
||||
|
||||
// ─── فك تشفير نص (يدعم CBC والـ GCM المستقبلي) ───────────
|
||||
public function decryptData(?string $cipherText): string|false
|
||||
{
|
||||
if (empty($cipherText)) return '';
|
||||
// تحقق إن كان مشفر بالنظام الجديد
|
||||
if (str_starts_with($cipherText, self::PREFIX_GCM)) {
|
||||
$raw = base64_decode(substr($cipherText, strlen(self::PREFIX_GCM)), true);
|
||||
if ($raw === false || strlen($raw) < self::IV_LEN_GCM + self::TAG_LEN) return false;
|
||||
|
||||
$iv = substr($raw, 0, self::IV_LEN_GCM);
|
||||
$tag = substr($raw, self::IV_LEN_GCM, self::TAG_LEN);
|
||||
$cipher = substr($raw, self::IV_LEN_GCM + self::TAG_LEN);
|
||||
|
||||
$plain = openssl_decrypt($cipher, self::ALGO_GCM, $this->key, OPENSSL_RAW_DATA, $iv, $tag);
|
||||
return $plain !== false ? $plain : false;
|
||||
}
|
||||
|
||||
// وإلا استخدم CBC القديم
|
||||
$decoded = base64_decode($cipherText, true);
|
||||
if ($decoded === false) return false;
|
||||
|
||||
$decrypted = openssl_decrypt($decoded, self::ALGO_CBC, $this->key, OPENSSL_RAW_DATA, $this->cbcIv);
|
||||
if ($decrypted === false) return false;
|
||||
|
||||
$pad = ord($decrypted[strlen($decrypted) - 1]);
|
||||
if ($pad < 1 || $pad > 16) return false;
|
||||
|
||||
return substr($decrypted, 0, -$pad);
|
||||
}
|
||||
|
||||
// ─── تشفير/فك تشفير Binary (صور، ملفات) ───────────────
|
||||
// تُستخدم الـ GCM مع IV عشوائي (كما في encryptData)
|
||||
public function encryptBinary(string $data): string
|
||||
{
|
||||
$iv = random_bytes(self::IV_LEN_GCM);
|
||||
$tag = '';
|
||||
$encrypted = openssl_encrypt($data, self::ALGO_GCM, $this->key, OPENSSL_RAW_DATA, $iv, $tag, "", self::TAG_LEN);
|
||||
return base64_encode($iv . $tag . $encrypted);
|
||||
}
|
||||
|
||||
public function decryptBinary(string $data): string|false
|
||||
{
|
||||
$raw = base64_decode($data, true);
|
||||
if ($raw === false || strlen($raw) < self::IV_LEN_GCM + self::TAG_LEN) return false;
|
||||
|
||||
$iv = substr($raw, 0, self::IV_LEN_GCM);
|
||||
$tag = substr($raw, self::IV_LEN_GCM, self::TAG_LEN);
|
||||
$cipher = substr($raw, self::IV_LEN_GCM + self::TAG_LEN);
|
||||
|
||||
return openssl_decrypt($cipher, self::ALGO_GCM, $this->key, OPENSSL_RAW_DATA, $iv, $tag);
|
||||
}
|
||||
|
||||
// --------- دوال الـ Padding للـ CBC ----------
|
||||
private function addPadding($data, $blockSize = 16) {
|
||||
$pad = $blockSize - (strlen($data) % $blockSize);
|
||||
return $data . str_repeat(chr($pad), $pad);
|
||||
}
|
||||
|
||||
private function removePadding($data) {
|
||||
$pad = ord($data[strlen($data) - 1]);
|
||||
return substr($data, 0, -$pad);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/Services/FcmService.php
|
||||
// إرسال FCM مع كاش توكن في Redis (بدل ملف)
|
||||
// ============================================================
|
||||
|
||||
class FcmService
|
||||
{
|
||||
private ?Redis $redis;
|
||||
private string $serviceAccountFile;
|
||||
|
||||
public function __construct(?Redis $redis = null)
|
||||
{
|
||||
$this->redis = $redis;
|
||||
$envPath = getenv('FIREBASE_SERVICE_ACCOUNT_PATH') ?: '';
|
||||
if (!empty($envPath) && file_exists($envPath)) {
|
||||
$this->serviceAccountFile = $envPath;
|
||||
} elseif (file_exists('/keys/firebase_service_account.json')) {
|
||||
$this->serviceAccountFile = '/keys/firebase_service_account.json';
|
||||
} elseif (file_exists('/keys/service-account.json')) {
|
||||
$this->serviceAccountFile = '/keys/service-account.json';
|
||||
} elseif (file_exists(__DIR__ . '/../../keys/firebase_service_account.json')) {
|
||||
$this->serviceAccountFile = __DIR__ . '/../../keys/firebase_service_account.json';
|
||||
} elseif (file_exists(__DIR__ . '/../../ride/firebase/service-account.json')) {
|
||||
$this->serviceAccountFile = __DIR__ . '/../../ride/firebase/service-account.json';
|
||||
} else {
|
||||
$this->serviceAccountFile = __DIR__ . '/../../keys/firebase_service_account.json'; // Default fallback
|
||||
}
|
||||
}
|
||||
|
||||
// ── إرسال إشعار ────────────────────────────────────────
|
||||
public function send(
|
||||
string $token,
|
||||
string $title,
|
||||
string $body,
|
||||
array $data = [],
|
||||
string $category = 'Order',
|
||||
string $tone = 'ding'
|
||||
): array {
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (!$accessToken) {
|
||||
return ['status' => 'error', 'message' => 'No access token'];
|
||||
}
|
||||
|
||||
if (!file_exists($this->serviceAccountFile)) {
|
||||
return ['status' => 'error', 'message' => 'Service account file missing'];
|
||||
}
|
||||
|
||||
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
|
||||
$projectId = $creds['project_id'];
|
||||
$fcmUrl = "https://fcm.googleapis.com/v1/projects/$projectId/messages:send";
|
||||
|
||||
$finalData = array_merge($data, [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'tone' => $tone,
|
||||
'category' => $category,
|
||||
'type' => $category,
|
||||
]);
|
||||
|
||||
// FCM يشترط أن تكون كل القيم strings
|
||||
$processedData = array_map(
|
||||
fn($v) => is_array($v) || is_object($v)
|
||||
? json_encode($v, JSON_UNESCAPED_UNICODE)
|
||||
: (string)$v,
|
||||
$finalData
|
||||
);
|
||||
|
||||
$payload = [
|
||||
'message' => [
|
||||
'token' => $token,
|
||||
'data' => $processedData,
|
||||
'android' => [
|
||||
'priority' => 'HIGH',
|
||||
'notification' => [
|
||||
'sound' => $tone === 'ding' ? 'default' : $tone,
|
||||
'channel_id' => 'high_importance_channel'
|
||||
]
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if (!empty($title) && !empty($body)) {
|
||||
$payload['message']['notification'] = [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
];
|
||||
$iosSound = $tone === 'ding' || $tone === 'default' ? 'default' : (str_ends_with($tone, '.caf') ? $tone : $tone . '.caf');
|
||||
$payload['message']['apns'] = [
|
||||
'payload' => [
|
||||
'aps' => [
|
||||
'sound' => $iosSound
|
||||
]
|
||||
]
|
||||
];
|
||||
} else {
|
||||
$payload['message']['apns'] = [
|
||||
'headers' => [
|
||||
'apns-priority' => '5',
|
||||
'apns-push-type' => 'background'
|
||||
],
|
||||
'payload' => [
|
||||
'aps' => [
|
||||
'content-available' => 1
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$ch = curl_init($fcmUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: Bearer $accessToken",
|
||||
'Content-Type: application/json; charset=UTF-8',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_FRESH_CONNECT => false, // إعادة استخدام الاتصال
|
||||
CURLOPT_FORBID_REUSE => false,
|
||||
CURLOPT_TCP_KEEPALIVE => 1,
|
||||
]);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErr = curl_errno($ch);
|
||||
curl_close($ch);
|
||||
|
||||
error_log("[FCM_DEBUG] Token: " . substr($token, 0, 10) . "... Payload: " . json_encode($payload, JSON_UNESCAPED_UNICODE) . " | Result: $httpCode - $result");
|
||||
|
||||
if ($curlErr) {
|
||||
return ['status' => 'error', 'message' => 'CURL error'];
|
||||
}
|
||||
|
||||
return $httpCode === 200
|
||||
? ['status' => 'success']
|
||||
: ['status' => 'error', 'code' => $httpCode, 'response' => $result];
|
||||
}
|
||||
|
||||
// ── إرسال إشعار لـ FCM Topic (قناة المواصلاتي وغيرها) ──
|
||||
public function sendToTopic(
|
||||
string $topic,
|
||||
string $title,
|
||||
string $body,
|
||||
array $data = []
|
||||
): array {
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (!$accessToken) return ['status' => 'error', 'message' => 'No access token'];
|
||||
|
||||
if (!file_exists($this->serviceAccountFile)) {
|
||||
return ['status' => 'error', 'message' => 'Service account file missing'];
|
||||
}
|
||||
|
||||
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
|
||||
$projectId = $creds['project_id'];
|
||||
$fcmUrl = "https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send";
|
||||
|
||||
$processedData = array_map(
|
||||
fn($v) => is_array($v) || is_object($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string)$v,
|
||||
array_merge($data, ['title' => $title, 'body' => $body])
|
||||
);
|
||||
|
||||
$payload = [
|
||||
'message' => [
|
||||
'topic' => $topic,
|
||||
'notification' => ['title' => $title, 'body' => $body],
|
||||
'data' => $processedData,
|
||||
'android' => ['priority' => 'HIGH'],
|
||||
],
|
||||
];
|
||||
|
||||
$ch = curl_init($fcmUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: Bearer $accessToken", 'Content-Type: application/json; charset=UTF-8'],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
]);
|
||||
$result = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $httpCode === 200
|
||||
? ['status' => 'success']
|
||||
: ['status' => 'error', 'code' => $httpCode, 'response' => $result];
|
||||
}
|
||||
|
||||
// ── Access Token مع Redis Cache ─────────────────────────
|
||||
private function getAccessToken(): ?string
|
||||
{
|
||||
// 1. من Redis
|
||||
if ($this->redis) {
|
||||
$cached = $this->redis->get('google_fcm_access_token');
|
||||
if ($cached) return $cached;
|
||||
}
|
||||
|
||||
// 2. طلب جديد
|
||||
$token = $this->fetchGoogleToken();
|
||||
|
||||
if ($token && $this->redis) {
|
||||
$this->redis->setex('google_fcm_access_token', 3500, $token);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function fetchGoogleToken(): ?string
|
||||
{
|
||||
if (!file_exists($this->serviceAccountFile)) return null;
|
||||
|
||||
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
|
||||
$clientEmail = $creds['client_email'];
|
||||
$privateKey = $creds['private_key'];
|
||||
$now = time();
|
||||
|
||||
$header = rtrim(strtr(base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), '+/', '-_'), '=');
|
||||
$claim = rtrim(strtr(base64_encode(json_encode([
|
||||
'iss' => $clientEmail,
|
||||
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now,
|
||||
])), '+/', '-_'), '=');
|
||||
|
||||
$signature = '';
|
||||
openssl_sign("$header.$claim", $signature, $privateKey, 'SHA256');
|
||||
$jwt = "$header.$claim." . rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
|
||||
|
||||
$ch = curl_init('https://oauth2.googleapis.com/token');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query([
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt,
|
||||
]),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$res = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return json_decode($res, true)['access_token'] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
/**
|
||||
* LocationIntelligenceEngine.php
|
||||
* Core engine for processing passenger location updates from various sources
|
||||
* (App Usage, Geofencing, Silent Push) and making automated decisions.
|
||||
*/
|
||||
|
||||
class LocationIntelligenceEngine {
|
||||
private $db;
|
||||
|
||||
public function __construct($dbConnection) {
|
||||
$this->db = $dbConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a location update from any source.
|
||||
*
|
||||
* @param int|string $passengerId
|
||||
* @param float $lat
|
||||
* @param float $lng
|
||||
* @param string $source Enum: 'app_usage', 'geofence', 'silent_push'
|
||||
* @return array Optional new geofence regions to register on user's device
|
||||
*/
|
||||
public function processLocationUpdate($passengerId, $lat, $lng, $source = 'app_usage', $batteryLevel = null) {
|
||||
if (!function_exists('sendFCM_Internal')) {
|
||||
require_once __DIR__ . '/../../functions.php';
|
||||
}
|
||||
|
||||
// 1. Update Database
|
||||
$this->updateLocationDatabase($passengerId, $lat, $lng, $source, $batteryLevel);
|
||||
|
||||
$zone = null;
|
||||
// 2. Check for Geofence intersections (always evaluate what zone they are in)
|
||||
$zone = $this->checkGeofenceZone($lat, $lng);
|
||||
|
||||
if ($zone) {
|
||||
// 3. Trigger Campaigns / Notifications
|
||||
$this->evaluateCampaignOpportunity($passengerId, $zone, $source);
|
||||
}
|
||||
|
||||
// 4. Update Driver Demand Map
|
||||
$this->updateDemandMap($passengerId, $lat, $lng);
|
||||
|
||||
// 5. Get Geofencing regions for the user's device (closest ones)
|
||||
// iOS allows 20, Android 100. We'll return top 20 by default.
|
||||
return $this->getUpdatedGeofencesForUser($lat, $lng);
|
||||
}
|
||||
|
||||
private function updateLocationDatabase($passengerId, $lat, $lng, $source, $batteryLevel) {
|
||||
try {
|
||||
$sql = "INSERT INTO passenger_opening_locations (passenger_id, latitude, longitude, source, battery_level)
|
||||
VALUES (:pid, :lat, :lng, :source, :battery)";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([
|
||||
':pid' => $passengerId,
|
||||
':lat' => $lat,
|
||||
':lng' => $lng,
|
||||
':source' => $source,
|
||||
':battery' => $batteryLevel
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
error_log("[LocationIntelligenceEngine] DB Error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function checkGeofenceZone($lat, $lng) {
|
||||
// Find if the user's lat/lng is within the radius of any active geofence zone
|
||||
// Using Haversine formula
|
||||
$sql = "SELECT id, zone_name, country_code, radius_meters,
|
||||
(6371000 * acos(cos(radians(:lat)) * cos(radians(latitude)) * cos(radians(longitude) - radians(:lng)) + sin(radians(:lat)) * sin(radians(latitude)))) AS distance
|
||||
FROM geofence_zones
|
||||
WHERE is_active = 1
|
||||
HAVING distance <= radius_meters
|
||||
ORDER BY distance ASC LIMIT 1";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([':lat' => $lat, ':lng' => $lng]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
private function evaluateCampaignOpportunity($passengerId, $zone, $source) {
|
||||
// 1. Check if passenger received a campaign recently (Anti-Spam)
|
||||
$sqlSpamCheck = "SELECT COUNT(*) FROM marketing_campaigns_log
|
||||
WHERE passenger_id = :pid
|
||||
AND message_type = 'push'
|
||||
AND sent_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)";
|
||||
$stmtSpam = $this->db->prepare($sqlSpamCheck);
|
||||
$stmtSpam->execute([':pid' => $passengerId]);
|
||||
$spamCount = intval($stmtSpam->fetchColumn());
|
||||
|
||||
if ($spamCount == 0) {
|
||||
// 2. Fetch Active Campaign (Placeholder for real campaign DB logic)
|
||||
// Currently, we just send a generic welcome to the zone if priority is high.
|
||||
if ($zone['priority'] >= 1) {
|
||||
$this->sendCampaignNotification($passengerId, $zone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sendCampaignNotification($passengerId, $zone) {
|
||||
// Get passenger token
|
||||
$sql = "SELECT t.token as users_token, p.country_code
|
||||
FROM passengers p
|
||||
JOIN tokens t ON p.id = t.passengerID
|
||||
WHERE p.id = :pid";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([':pid' => $passengerId]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($user && !empty($user['users_token'])) {
|
||||
$title = "مرحباً بك في " . $zone['zone_name'] . " 🎉";
|
||||
$body = "اطلب رحلتك الآن من " . $zone['zone_name'] . " واستمتع بتجربة سيرو!";
|
||||
|
||||
// Decrypt token
|
||||
require_once __DIR__ . '/../Security/EncryptionHelper.php';
|
||||
$encryptionHelper = new EncryptionHelper();
|
||||
$decryptedToken = $encryptionHelper->decryptData($user['users_token']);
|
||||
|
||||
if ($decryptedToken) {
|
||||
// Send Push
|
||||
sendFCM_Internal($decryptedToken, $title, $body, ['type' => 'geofence_promo'], 'Marketing');
|
||||
}
|
||||
|
||||
// Log it
|
||||
$logSql = "INSERT INTO marketing_campaigns_log (passenger_id, message_type, country_code, region_name, triggered_by)
|
||||
VALUES (:pid, 'push', :country, :region, 'geofence_trigger')";
|
||||
$logStmt = $this->db->prepare($logSql);
|
||||
$logStmt->execute([
|
||||
':pid' => $passengerId,
|
||||
':country' => $user['country_code'] ?? 'JO',
|
||||
':region' => $zone['zone_name']
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function updateDemandMap($passengerId, $lat, $lng) {
|
||||
// Broadcast this location to drivers or update a demand heat map cache
|
||||
// Here we insert into passengerlocation to log the hotspot.
|
||||
try {
|
||||
$sql = "INSERT INTO passengerlocation (passengerId, lat, lng, rideId) VALUES (:pid, :lat, :lng, '0')";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
// $stmt->execute([':pid' => $passengerId, ':lat' => $lat, ':lng' => $lng]); // Suppressed for now to avoid db constraints errors
|
||||
} catch (Exception $e) {
|
||||
error_log("[LocationIntelligenceEngine] Demand Map Error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function getUpdatedGeofencesForUser($lat, $lng, $limit = 20) {
|
||||
// Return top nearest geofences to update on the user's device
|
||||
$sql = "SELECT id, zone_name, latitude, longitude, radius_meters,
|
||||
(6371000 * acos(cos(radians(:lat)) * cos(radians(latitude)) * cos(radians(longitude) - radians(:lng)) + sin(radians(:lat)) * sin(radians(latitude)))) AS distance
|
||||
FROM geofence_zones
|
||||
WHERE is_active = 1
|
||||
ORDER BY distance ASC LIMIT :limit";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->bindParam(':lat', $lat);
|
||||
$stmt->bindParam(':lng', $lng);
|
||||
$stmt->bindValue(':limit', (int) $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/Services/OtpService.php
|
||||
// تخزين OTP في Redis بدلاً من MySQL (أسرع وأخف)
|
||||
// ============================================================
|
||||
|
||||
class OtpService
|
||||
{
|
||||
private ?Redis $redis;
|
||||
private const OTP_TTL = 300; // 5 دقائق
|
||||
private const MAX_ATTEMPTS = 3;
|
||||
private const LOCKOUT_TTL = 1800; // 30 دقيقة إذا تجاوز المحاولات
|
||||
|
||||
public function __construct(?Redis $redis)
|
||||
{
|
||||
$this->redis = $redis;
|
||||
}
|
||||
|
||||
// ── توليد وحفظ OTP ─────────────────────────────────────
|
||||
// $digits: عدد الأرقام — الافتراضي 6، يمكن تمرير 3 لـ transit (100–999)
|
||||
public function generate(string $phone, int $digits = 3): string
|
||||
{
|
||||
$min = (int)str_pad('1', $digits, '0'); // digits=3 → 100 | digits=6 → 100000
|
||||
$max = (int)str_pad('9', $digits, '9'); // digits=3 → 999 | digits=6 → 999999
|
||||
$otp = str_pad((string)random_int($min, $max), $digits, '0', STR_PAD_LEFT);
|
||||
|
||||
if ($this->redis) {
|
||||
$key = "otp:{$phone}";
|
||||
$this->redis->setex($key, self::OTP_TTL, password_hash($otp, PASSWORD_BCRYPT));
|
||||
$this->redis->del("otp:attempts:{$phone}");
|
||||
}
|
||||
|
||||
return $otp;
|
||||
}
|
||||
|
||||
// ── التحقق من OTP ───────────────────────────────────────
|
||||
public function verify(string $phone, string $inputOtp): bool
|
||||
{
|
||||
if (!$this->redis) return false;
|
||||
|
||||
// فحص الـ lockout
|
||||
if ($this->redis->exists("otp:locked:{$phone}")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$key = "otp:{$phone}";
|
||||
$stored = $this->redis->get($key);
|
||||
|
||||
if (!$stored) {
|
||||
return false; // انتهت صلاحية الـ OTP
|
||||
}
|
||||
|
||||
$attemptsKey = "otp:attempts:{$phone}";
|
||||
|
||||
if (!password_verify($inputOtp, $stored)) {
|
||||
$attempts = $this->redis->incr($attemptsKey);
|
||||
$this->redis->expire($attemptsKey, self::OTP_TTL);
|
||||
|
||||
if ($attempts >= self::MAX_ATTEMPTS) {
|
||||
// قفل لمدة 30 دقيقة
|
||||
$this->redis->setex("otp:locked:{$phone}", self::LOCKOUT_TTL, '1');
|
||||
$this->redis->del($key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// نجح التحقق — احذف الـ OTP
|
||||
$this->redis->del($key);
|
||||
$this->redis->del($attemptsKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── فحص هل الرقم مقفل ──────────────────────────────────
|
||||
public function isLocked(string $phone): bool
|
||||
{
|
||||
return $this->redis && (bool)$this->redis->exists("otp:locked:{$phone}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/Services/SiroGeminiService.php
|
||||
// Siro AI Market Analysis & Marketing Content Generation Service
|
||||
// ============================================================
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class SiroGeminiService {
|
||||
private ?string $apiKey;
|
||||
private string $baseUrl;
|
||||
|
||||
public function __construct() {
|
||||
$this->apiKey = getenv('GEMINI_API_KEY') ?: null;
|
||||
$this->baseUrl = "https://generativelanguage.googleapis.com/v1beta/models/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze market prices and generate target promotion response
|
||||
*
|
||||
* @param array $competitorPrices Array of competitor pricing information
|
||||
* @param float $siroBasePrice Current Siro base pricing for this region/country
|
||||
* @param string $regionName Name of the region/city
|
||||
* @param string $countryCode Country code (e.g. SY, JO, EG, IQ)
|
||||
* @param string $model Override AI model to use (default: gemini-1.5-flash)
|
||||
* @return array|null Decoded JSON response from Gemini or null on failure
|
||||
*/
|
||||
public function analyzeMarketAndDraftCampaign(
|
||||
array $competitorPrices,
|
||||
float $siroBasePrice,
|
||||
string $regionName,
|
||||
string $countryCode,
|
||||
string $model = 'gemini-flash-lite-latest'
|
||||
): ?array {
|
||||
if (!$this->apiKey) {
|
||||
error_log("[SiroGeminiService] API Key is missing.");
|
||||
return null;
|
||||
}
|
||||
|
||||
$dialect = match (strtoupper($countryCode)) {
|
||||
'SY' => 'السورية (الشامية)',
|
||||
'JO' => 'الأردنية',
|
||||
'EG' => 'المصرية',
|
||||
'IQ' => 'العراقية',
|
||||
default => 'العربية الفصحى البسيطة'
|
||||
};
|
||||
|
||||
$prompt = "
|
||||
أنت خبير تسويق ذكي ومحلل أسعار لتطبيق Siro لخدمات نقل الركاب.
|
||||
قم بتحليل أسعار المنافسين في منطقة '$regionName' وصياغة حملة تسويقية وعرض ترويجي منافس.
|
||||
|
||||
بيانات الإدخال:
|
||||
1. أسعار المنافسين الحالية: " . json_encode($competitorPrices, JSON_UNESCAPED_UNICODE) . "
|
||||
2. سعر رحلة Siro الأساسي الحالي: $siroBasePrice
|
||||
|
||||
المطلوب:
|
||||
1. دراسة الأسعار وتحديد هل توجد فرصة تسويقية واضحة لجذب الركاب (opportunity_detected: true/false).
|
||||
2. تحديد معامل التخفيض المقترح أو قيمة خصم مناسبة (discount_value) والنسبة المئوية (discount_percentage).
|
||||
3. كتابة رسالة تسويقية إعلانية جذابة وقصيرة جداً ومقنعة لإرسالها كإشعار (Push Notification) للركاب النشطين بالهجة $dialect.
|
||||
4. كتابة رسالة استعادة جذابة ومغرية وقصيرة لإرسالها عبر SMS أو WhatsApp للركاب المنقطعين بالهجة $dialect مع ذكر كود الخصم المقترح.
|
||||
5. اقتراح كود خصم مناسب للحملة (promo_code) ليكون سهل الحفظ ومناسباً للحدث.
|
||||
|
||||
الخرج المطلوب (يجب أن يكون JSON صالحاً تماماً وخالياً من أي شرح خارجي، باللغة العربية):
|
||||
{
|
||||
\"opportunity_detected\": true/false,
|
||||
\"recommended_price\": 12000,
|
||||
\"discount_percentage\": 15,
|
||||
\"promo_code\": \"SIROGO15\",
|
||||
\"push_title\": \"عنوان الإشعار (بحد أقصى 5 كلمات)\",
|
||||
\"push_body\": \"محتوى الإشعار القصير والمثير للاهتمام (بحد أقصى 15 كلمة)\",
|
||||
\"sms_body\": \"محتوى رسالة الاستعادة (بحد أقصى 20 كلمة)\"
|
||||
}
|
||||
";
|
||||
|
||||
return $this->callGemini($prompt, $model);
|
||||
}
|
||||
|
||||
/**
|
||||
* يُولّد إشعاراً مخصصاً لحدث دخول الجيوفينس بناءً على بيانات أسعار حقيقية.
|
||||
*
|
||||
* @param string $zoneName اسم منطقة السياج الجغرافي
|
||||
* @param string $countryCode رمز الدولة (SY, JO, EG, IQ)
|
||||
* @param float $savingsPct نسبة التوفير (مثال: 8.5)
|
||||
* @param string $topCompetitor اسم أبرز منافس في المنطقة
|
||||
* @param string $model
|
||||
* @return array|null
|
||||
*/
|
||||
public function generateGeofenceMessage(
|
||||
string $zoneName,
|
||||
string $countryCode,
|
||||
float $savingsPct,
|
||||
string $topCompetitor = 'كريم',
|
||||
string $model = 'gemini-flash-lite-latest'
|
||||
): ?array {
|
||||
if (!$this->apiKey) return null;
|
||||
|
||||
$dialect = match (strtoupper($countryCode)) {
|
||||
'SY' => 'السورية الشامية',
|
||||
'JO' => 'الأردنية',
|
||||
'EG' => 'المصرية العامية',
|
||||
'IQ' => 'العراقية',
|
||||
default => 'العربية الفصحى'
|
||||
};
|
||||
|
||||
$savingsFormatted = number_format($savingsPct, 1);
|
||||
|
||||
$prompt = "
|
||||
أنت كاتب إشعارات تسويقية ذكية لتطبيق Siro لخدمات نقل الركاب.
|
||||
المستخدم الآن موجود بالقرب من '$zoneName'.
|
||||
سعر سيرو أقل بـ $savingsFormatted% من $topCompetitor وبقية التطبيقات في هذه المنطقة.
|
||||
|
||||
المطلوب: اكتب إشعاراً push قصيراً جداً (عنوان + جسم) باللهجة $dialect.
|
||||
- العنوان: لا يتجاوز 5 كلمات، مثير للاهتمام
|
||||
- الجسم: لا يتجاوز 12 كلمة، يذكر التوفير الفعلي ويحفّز على الطلب الآن
|
||||
- اجعله طبيعياً كأنه يكتبه شخص حقيقي وليس روبوت
|
||||
|
||||
الخرج (JSON فقط، بدون أي شرح):
|
||||
{
|
||||
\"push_title\": \"...\",
|
||||
\"push_body\": \"...\"
|
||||
}
|
||||
";
|
||||
|
||||
return $this->callGemini($prompt, $model);
|
||||
}
|
||||
|
||||
/**
|
||||
* تقرأ معادلات المنافسين المكتشفة أسبوعياً وتقدم استراتيجية تسويق (Weekly Advisor).
|
||||
*
|
||||
* @param array $formulas المصفوفة المستخرجة من competitor_secret_formulas
|
||||
* @param string $model
|
||||
* @return array|null
|
||||
*/
|
||||
public function analyzeCompetitorFormulas(
|
||||
array $formulas,
|
||||
string $model = 'gemini-flash-lite-latest'
|
||||
): ?array {
|
||||
if (!$this->apiKey) return null;
|
||||
|
||||
$formulasJson = json_encode($formulas, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$prompt = "
|
||||
أنت المستشار التسويقي المالي لتطبيق 'سيرو' لنقل الركاب.
|
||||
لقد قمنا بعمل هندسة عكسية لرحلات منافسينا واكتشفنا معادلات التسعير السرية الخاصة بهم لهذا الأسبوع.
|
||||
|
||||
البيانات المكتشفة:
|
||||
$formulasJson
|
||||
|
||||
المطلوب منك (كمستشار أعمال خبير):
|
||||
1. تحليل هذه الأرقام، وتوضيح أين تكمن نقاط قوة المنافسين وأين نقاط ضعفهم في التسعير (مثلاً من يركز على المسافات القصيرة برفع فتح العداد؟).
|
||||
2. اقتراح 3 استراتيجيات تسويقية أو رسائل إعلانية (Push Notifications/Social Media) موجهة للركاب، تستغل نقاط ضعف المنافسين المكتشفة.
|
||||
|
||||
قم بصياغة تقريرك بتنسيق HTML مرتب وجاهز للعرض في لوحة الإدارة (بدون علامات ```html)، واستخدم ألوان خفيفة في العناوين <h3>.
|
||||
تحدث بلغة عربية احترافية ومباشرة لصناع القرار.
|
||||
";
|
||||
|
||||
// بما أن الإخراج سيكون نص HTML، سنستخدم API جيميناي العادي بدون تقييد JSON
|
||||
$url = $this->baseUrl . "{$model}:generateContent?key={$this->apiKey}";
|
||||
|
||||
$postData = [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
['text' => $prompt]
|
||||
]
|
||||
]
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.7,
|
||||
'maxOutputTokens' => 2000
|
||||
]
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200) {
|
||||
$data = json_decode($response, true);
|
||||
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
return ['status' => 'success', 'html_report' => $text];
|
||||
} else {
|
||||
error_log("[SiroGeminiService] HTTP $httpCode in analyzeCompetitorFormulas: $response");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: يُرسل prompt لـ Gemini ويُعيد JSON مُفكَّك
|
||||
*/
|
||||
public function callGemini(string $prompt, string $model): ?array {
|
||||
$apiUrl = $this->baseUrl . $model . ":generateContent?key=" . $this->apiKey;
|
||||
|
||||
$payload = [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
['text' => $prompt]
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$ch = curl_init($apiUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CONNECTTIMEOUT => 5
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$curlErr = curl_error($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlErr) {
|
||||
error_log("[SiroGeminiService] Curl Error: $curlErr");
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
error_log("[SiroGeminiService] HTTP Error $httpCode. Response: $response");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$responseData = json_decode($response, true);
|
||||
$rawText = $responseData['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
// تنظيف أي علامات كود ماركداون محتملة من الموديل
|
||||
$cleanJson = trim(preg_replace('/```json|```/', '', $rawText));
|
||||
|
||||
$decoded = json_decode($cleanJson, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
error_log("[SiroGeminiService] JSON Decode Error: " . json_last_error_msg() . " | Raw text: $rawText");
|
||||
return null;
|
||||
}
|
||||
return $decoded;
|
||||
} catch (Exception $e) {
|
||||
error_log("[SiroGeminiService] Exception: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function evaluatePostsForReporting(array $posts, string $model = 'gemini-flash-lite-latest'): ?string {
|
||||
if (!$this->apiKey || empty($posts)) return null;
|
||||
|
||||
$postsJson = json_encode($posts, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$prompt = "
|
||||
أنت محلل بيانات استخباراتية للسوق لتطبيق 'سيرو' لنقل الركاب.
|
||||
إليك مجموعة من المنشورات والتعليقات التي جمعها الروبوت الخاص بنا من مجموعات فيسبوك اليوم:
|
||||
$postsJson
|
||||
|
||||
المطلوب منك:
|
||||
1. قراءة جميع هذه المنشورات واستخراج أي شكاوى، أسئلة، أو نقاشات تتعلق بـ (تطبيقات النقل الذكي، أسعار المحروقات، باقات الإنترنت، مشاكل السيارات).
|
||||
ملاحظة هامة عن بنية البيانات:
|
||||
- أي نص يبدأ بـ [FEED]: هو عبارة عن المنشور الأصلي (البوست).
|
||||
- أي نص يبدأ بـ [COMMENT]: هو تعليق تابع للمنشور الذي يسبقه مباشرة.
|
||||
2. تلخيص أهم هذه النقاشات في تقرير قصير ومفيد (News Report). يرجى التمييز بين المنشور الأصلي والتعليقات عليه لفهم السياق.
|
||||
3. تجاهل المنشورات العشوائية أو الشخصية التي لا تفيد السوق.
|
||||
|
||||
هام جداً:
|
||||
- يجب أن يكون التقرير باللغة العربية بالكامل.
|
||||
- يجب أن تغلف التقرير بالكامل بوسم <div dir=\"rtl\" align=\"right\"> في البداية و </div> في النهاية لضمان القراءة من اليمين لليسار.
|
||||
- استخدم <h3> للعناوين و <ul> للقوائم داخل التقرير.
|
||||
- لا تقم بتضمين علامات ```html في المخرجات.
|
||||
إذا لم يكن هناك أي شيء مفيد، اكتب فقط: <div dir=\"rtl\" align=\"right\"><p>لا توجد بيانات مفيدة في هذه الدفعة.</p></div>
|
||||
";
|
||||
|
||||
$url = $this->baseUrl . "{$model}:generateContent?key={$this->apiKey}";
|
||||
|
||||
$postData = [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
['text' => $prompt]
|
||||
]
|
||||
]
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.5,
|
||||
'maxOutputTokens' => 1500
|
||||
]
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200) {
|
||||
$data = json_decode($response, true);
|
||||
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
return trim(preg_replace('/```html|```/', '', $text));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function evaluateTelegramForReporting(array $messages, string $model = 'gemini-flash-lite-latest'): ?string {
|
||||
if (!$this->apiKey || empty($messages)) return null;
|
||||
|
||||
$messagesJson = json_encode($messages, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$prompt = "
|
||||
أنت محلل بيانات استخباراتية للسوق لتطبيق 'سيرو' لنقل الركاب.
|
||||
إليك مجموعة من الرسائل التي جمعها الروبوت الخاص بنا من قنوات ومجموعات السائقين على تليغرام اليوم:
|
||||
$messagesJson
|
||||
|
||||
المطلوب منك:
|
||||
1. قراءة جميع هذه الرسائل واستخراج أي شكاوى، أسئلة، أو نقاشات تتعلق بـ (تطبيقات النقل الذكي مثل كريم، أوبر، جيني، يلاغو، تكسي إف، أسعار المحروقات، مشاكل التطبيقات، الإضرابات).
|
||||
ملاحظة هامة عن بنية البيانات:
|
||||
- كل رسالة تبدأ بـ [TELEGRAM]: هي عبارة عن رسالة مرسلة في مجموعة تليغرام.
|
||||
2. تلخيص أهم هذه النقاشات في تقرير قصير ومفيد (News Report).
|
||||
3. تجاهل رسائل الانضمام للمجموعات، الملصقات، الإعلانات العشوائية، أو الرسائل القصيرة جداً التي لا تحتوي على معنى مفيد للسوق.
|
||||
|
||||
هام جداً:
|
||||
- يجب أن يكون التقرير باللغة العربية بالكامل.
|
||||
- يجب أن تغلف التقرير بالكامل بوسم <div dir=\"rtl\" align=\"right\"> في البداية و </div> في النهاية لضمان القراءة من اليمين لليسار.
|
||||
- استخدم <h3> للعناوين و <ul> للقوائم داخل التقرير.
|
||||
- لا تقم بتضمين علامات ```html في المخرجات.
|
||||
إذا لم يكن هناك أي شيء مفيد، اكتب فقط: <div dir=\"rtl\" align=\"right\"><p>لا توجد بيانات مفيدة في هذه الدفعة.</p></div>
|
||||
";
|
||||
|
||||
$url = $this->baseUrl . "{$model}:generateContent?key={$this->apiKey}";
|
||||
|
||||
$postData = [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
['text' => $prompt]
|
||||
]
|
||||
]
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.5,
|
||||
'maxOutputTokens' => 1500
|
||||
]
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
echo "\n[GEMINI API ERROR] HTTP Code: $httpCode\n";
|
||||
echo "Response: $response\n\n";
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
return trim(preg_replace('/```html|```/', '', $text));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/bootstrap.php
|
||||
// البوابة الرئيسية الموحدة لكل التطبيق
|
||||
// ============================================================
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// 1. إعدادات الأخطاء والـ Headers الأساسية
|
||||
// اجعل القيمة true لتفعيل عرض الأخطاء (التطوير)، أو false لإخفائها (التشغيل الفعلي)
|
||||
$debugMode = getenv('APP_DEBUG') === 'true';
|
||||
|
||||
if ($debugMode || php_sapi_name() === 'cli') {
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
} else {
|
||||
error_reporting(0);
|
||||
ini_set('display_errors', '0');
|
||||
}
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
// تحديد مسار اللوج بشكل ديناميكي (محلياً أو سيرفر)
|
||||
$logPath = getenv('ERROR_LOG_PATH') ?: (__DIR__ . '/../logs/php_errors.log');
|
||||
ini_set('error_log', $logPath);
|
||||
|
||||
// تعريف الدولة أو البيئة الحالية للسيرفر (مثلاً: syria, egypt, jordan)
|
||||
// تُستخدم لتوجيه الروابط أو لتحديد السيرفر
|
||||
$globalCountry = getenv('GLOBAL_COUNTRY') ?: 'syria';
|
||||
if (!defined('GLOBAL_COUNTRY')) {
|
||||
define('GLOBAL_COUNTRY', $globalCountry);
|
||||
}
|
||||
|
||||
header_remove('X-Powered-By');
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'");
|
||||
header("Referrer-Policy: strict-origin-when-cross-origin");
|
||||
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
|
||||
header("X-XSS-Protection: 1; mode=block");
|
||||
|
||||
|
||||
// CORS مع التحقق من المصدر المسموح
|
||||
$envOrigins = array_map('trim', explode(',', getenv('CORS_ALLOWED_ORIGINS') ?: ''));
|
||||
$defaultOrigins = ['https://siromove.com', 'https://admin.siromove.com', 'https://jordan-siro.intaleqapp.com', 'http://localhost', 'http://127.0.0.1'];
|
||||
$allowedOrigins = array_unique(array_merge($envOrigins, $defaultOrigins));
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if (in_array($origin, $allowedOrigins)) {
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
}
|
||||
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Device-FP, X-HMAC-Auth, X-Internal-Key');
|
||||
|
||||
// REQUEST_METHOD غير معرّف عند التشغيل من سطر الأوامر (سكربتات الترحيل)
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Autoload
|
||||
$vendorPath = realpath(__DIR__ . '/../../vendor/autoload.php');
|
||||
if (!$vendorPath) {
|
||||
$vendorPath = realpath(__DIR__ . '/../vendor/autoload.php');
|
||||
}
|
||||
if ($vendorPath) require_once $vendorPath;
|
||||
|
||||
// 3. Helpers & Env
|
||||
require_once __DIR__ . '/helpers.php';
|
||||
|
||||
// تحديد مسار الـ .env بشكل ديناميكي
|
||||
if (preg_match('#^(/home/[^/]+)#', __DIR__, $matches)) {
|
||||
$homeDir = $matches[1];
|
||||
} else {
|
||||
$homeDir = dirname($_SERVER['DOCUMENT_ROOT'] ?? __DIR__);
|
||||
}
|
||||
|
||||
$envFile = getenv('ENV_FILE_PATH') ?: ($homeDir . '/.env');
|
||||
if (!file_exists($envFile)) {
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
}
|
||||
loadEnvironment($envFile);
|
||||
|
||||
// تعيين مسارات المفاتيح تلقائياً إذا لم تكن معرفة في الـ .env
|
||||
if (!getenv('ENCRYPTION_KEY_PATH')) {
|
||||
$encKeyDefault = "$homeDir/.enckey";
|
||||
putenv("ENCRYPTION_KEY_PATH=$encKeyDefault");
|
||||
$_ENV['ENCRYPTION_KEY_PATH'] = $encKeyDefault;
|
||||
}
|
||||
if (!getenv('SECRET_KEY_PATH')) {
|
||||
$secKeyDefault = "$homeDir/.secret_key";
|
||||
putenv("SECRET_KEY_PATH=$secKeyDefault");
|
||||
$_ENV['SECRET_KEY_PATH'] = $secKeyDefault;
|
||||
}
|
||||
if (!getenv('SECRET_KEY_PAY_PATH')) {
|
||||
$secPayKeyDefault = "$homeDir/.secret_key_pay";
|
||||
putenv("SECRET_KEY_PAY_PATH=$secPayKeyDefault");
|
||||
$_ENV['SECRET_KEY_PAY_PATH'] = $secPayKeyDefault;
|
||||
}
|
||||
if (!getenv('INTERNAL_SOCKET_KEY_PATH')) {
|
||||
$sockKeyDefault = "$homeDir/.internal_socket_key";
|
||||
putenv("INTERNAL_SOCKET_KEY_PATH=$sockKeyDefault");
|
||||
$_ENV['INTERNAL_SOCKET_KEY_PATH'] = $sockKeyDefault;
|
||||
}
|
||||
if (!getenv('SERVICE_ACCOUNT_FILE_PATH')) {
|
||||
$svcAcctDefault = "$homeDir/service-account.json";
|
||||
putenv("SERVICE_ACCOUNT_FILE_PATH=$svcAcctDefault");
|
||||
$_ENV['SERVICE_ACCOUNT_FILE_PATH'] = $svcAcctDefault;
|
||||
}
|
||||
|
||||
// 4. Redis Connections (Dual Architecture)
|
||||
$redis = null;
|
||||
$redisLocation = null;
|
||||
try {
|
||||
if (extension_loaded('redis')) {
|
||||
// --- Main Server Redis ---
|
||||
$redis = new Redis();
|
||||
$redisHost = getenv('REDIS_MAIN_HOST') ?: getenv('REDIS_HOST') ?: '127.0.0.1';
|
||||
$redisPort = (int)(getenv('REDIS_MAIN_PORT') ?: getenv('REDIS_PORT') ?: 6379);
|
||||
$redisPass = getenv('REDIS_MAIN_PASSWORD') ?: getenv('REDIS_MAIN_AUTH') ?: getenv('REDIS_PASSWORD') ?: getenv('REDIS_AUTH');
|
||||
|
||||
if ($redis->connect($redisHost, $redisPort, 1.5)) {
|
||||
if ($redisPass) $redis->auth($redisPass);
|
||||
$redis->setOption(Redis::OPT_PREFIX, 'siro:');
|
||||
} else {
|
||||
$redis = null;
|
||||
}
|
||||
|
||||
// --- Location Server Redis ---
|
||||
$redisLocation = new Redis();
|
||||
// 🔥 [Fix Silent Fallback] إذا لم تُضبط REDIS_LOCATION_HOST صراحة، نسقط
|
||||
// على Redis الرئيسي — وهذا يجعل استعلامات كثافة السائقين (geo:drivers:*)
|
||||
// ترجع فارغة بصمت لأن تلك المفاتيح تُكتب فقط على Redis الخاص بلوكيشن
|
||||
// سيرفر. نسجّل تحذيراً واضحاً حتى لا يمر هذا دون ملاحظة في اللوجز.
|
||||
$locHostConfigured = getenv('REDIS_LOCATION_HOST');
|
||||
if (!$locHostConfigured) {
|
||||
error_log('[REDIS] ⚠️ REDIS_LOCATION_HOST is not set — $redisLocation is falling back to the MAIN redis host (' . $redisHost . '). ' .
|
||||
'geo:drivers:available / driver:profile:* / driver:public:* keys live only on the location-server Redis, ' .
|
||||
'so driver-density lookups (getSpeed.php, heatmap_live.php, pricing/get.php) will silently return empty results ' .
|
||||
'unless REDIS_LOCATION_HOST/PORT/PASSWORD are configured correctly in .env.');
|
||||
}
|
||||
$locHost = $locHostConfigured ?: $redisHost;
|
||||
$locPort = (int)(getenv('REDIS_LOCATION_PORT') ?: $redisPort);
|
||||
$locPass = getenv('REDIS_LOCATION_PASSWORD') ?: $redisPass;
|
||||
|
||||
if ($redisLocation->connect($locHost, $locPort, 1.5)) {
|
||||
if ($locPass) $redisLocation->auth($locPass);
|
||||
// No prefix for location server
|
||||
} else {
|
||||
error_log("[REDIS] ⚠️ Failed to connect \$redisLocation to $locHost:$locPort — driver-density features will be degraded.");
|
||||
$redisLocation = null;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("[REDIS] Connection failed: " . $e->getMessage());
|
||||
$redis = null;
|
||||
$redisLocation = null;
|
||||
}
|
||||
|
||||
// 5. تحميل الـ Services الأساسية
|
||||
require_once __DIR__ . '/Security/EncryptionHelper.php';
|
||||
require_once __DIR__ . '/Security/BlindIndex.php';
|
||||
|
||||
// فهرس البحث الأعمى — اختياري: إن لم يُضبط BLIND_INDEX_PEPPER تبقى نقاط
|
||||
// البحث تعمل بأسلوبها القديم بدل أن تفشل.
|
||||
$blindIndex = null;
|
||||
try {
|
||||
$blindIndex = new BlindIndex();
|
||||
} catch (Throwable $e) {
|
||||
error_log('[BlindIndex] disabled: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/Database/Database.php';
|
||||
require_once __DIR__ . '/Auth/RateLimiter.php';
|
||||
require_once __DIR__ . '/Auth/JwtService.php';
|
||||
// لا نحمّل OtpService و FcmService إلا عند الحاجة (Lazy)
|
||||
|
||||
// 6. تهيئة Encryption Helper العام (للتوافقية)
|
||||
// يتم استخدام .enckey (32 بايت) لتشفير البيانات
|
||||
$encKeyPath = getenv('ENCRYPTION_KEY_PATH');
|
||||
$encKey = '';
|
||||
if ($encKeyPath && file_exists($encKeyPath)) {
|
||||
$encKey = trim(@file_get_contents($encKeyPath) ?: '');
|
||||
}
|
||||
if (!$encKey) {
|
||||
$encKey = getenv('ENC_KEY') ?: '';
|
||||
}
|
||||
|
||||
if (!$encKey || strlen($encKey) !== 32) {
|
||||
error_log("[FATAL] Encryption key (.enckey) is missing or invalid length (must be 32 bytes).");
|
||||
http_response_code(500);
|
||||
exit(json_encode(['error' => 'Server configuration error: Encryption key issue']));
|
||||
}
|
||||
|
||||
$encryptionHelper = new EncryptionHelper($encKey);
|
||||
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/helpers.php — دوال مساعدة موحدة
|
||||
// ============================================================
|
||||
|
||||
// ── فلترة المدخلات (محسّنة) ─────────────────────────────────
|
||||
function filterRequest(string $name, string $type = 'string'): mixed
|
||||
{
|
||||
// قراءة من POST أو JSON body
|
||||
$value = null;
|
||||
|
||||
if (isset($_POST[$name]) && $_POST[$name] !== '') {
|
||||
$value = $_POST[$name];
|
||||
} else {
|
||||
// محاولة قراءة من JSON body
|
||||
static $jsonBody = null;
|
||||
if ($jsonBody === null) {
|
||||
$raw = file_get_contents('php://input');
|
||||
$jsonBody = json_decode($raw, true) ?? [];
|
||||
}
|
||||
$value = $jsonBody[$name] ?? null;
|
||||
}
|
||||
|
||||
if ($value === null || $value === '') return null;
|
||||
|
||||
$value = trim((string)$value);
|
||||
|
||||
// إزالة control characters
|
||||
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value);
|
||||
|
||||
return match ($type) {
|
||||
'int' => filter_var($value, FILTER_VALIDATE_INT) !== false ? (int)$value : null,
|
||||
'float' => filter_var($value, FILTER_VALIDATE_FLOAT) !== false ? (float)$value : null,
|
||||
'email' => filter_var($value, FILTER_VALIDATE_EMAIL) ?: null,
|
||||
'url' => filter_var($value, FILTER_VALIDATE_URL) ?: null,
|
||||
'bool' => filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE),
|
||||
default => $value, // string — بدون htmlspecialchars (نتركه لـ PDO)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* مفتاح بحث ثابت لجداول التحقق (token_verification*, phone_verification*).
|
||||
*
|
||||
* هذه الجداول تستخدم رقم الهاتف كمفتاح بحث لا كبيان يُعرض: يُكتب عند الإرسال
|
||||
* ويُقرأ عند التحقق. تخزينه مشفّراً كان يعمل فقط لأن التشفير حتمي — ومع
|
||||
* AES-GCM العشوائي يُنتج الإرسال والتحقق قيمتين مختلفتين فلا ينجح أي رمز.
|
||||
*
|
||||
* البديل: بصمة HMAC حتمية للرقم بعد تطبيعه. لا تحتاج تعديل المخطط (العمود
|
||||
* نصي أصلاً)، وتوحّد صيغ الرقم المحلية والدولية، ولا يمكن عكسها بلا المفتاح.
|
||||
*/
|
||||
function otpPhoneKey(?string $phone): string
|
||||
{
|
||||
if ($phone === null || trim($phone) === '') return '';
|
||||
|
||||
global $blindIndex, $encryptionHelper;
|
||||
|
||||
if ($blindIndex) {
|
||||
return 'K:' . $blindIndex->index('otp.phone', $phone);
|
||||
}
|
||||
|
||||
// بلا BLIND_INDEX_PEPPER نعود للسلوك القديم حتى لا يتعطل التحقق
|
||||
return $encryptionHelper ? $encryptionHelper->encryptData($phone) : $phone;
|
||||
}
|
||||
|
||||
// ── ردود JSON موحدة ─────────────────────────────────────────
|
||||
function jsonSuccess(mixed $data = null, string $message = 'success', int $code = 200): never
|
||||
{
|
||||
http_response_code($code);
|
||||
// توحيد الأسلوب ليكون متوافقاً مع الكود القديم (وضع البيانات في message)
|
||||
$payload = ($data !== null && (!empty($data) || is_array($data))) ? $data : $message;
|
||||
echo json_encode(['status' => 'success', 'message' => $payload], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
function jsonError(string $message, int $code = 400, mixed $extra = null): never
|
||||
{
|
||||
http_response_code($code);
|
||||
$response = ['status' => 'failure', 'message' => $message];
|
||||
if ($extra !== null) $response['details'] = $extra;
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
// (للتوافق مع الكود القديم)
|
||||
function printSuccess(mixed $message = 'success'): void
|
||||
{
|
||||
echo json_encode(['status' => 'success', 'message' => $message], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
function printFailure(mixed $message = 'failure'): void
|
||||
{
|
||||
echo json_encode(['status' => 'failure', 'message' => $message], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
function result(int $count): void
|
||||
{
|
||||
if ($count > 0) {
|
||||
printSuccess();
|
||||
} else {
|
||||
printFailure();
|
||||
}
|
||||
}
|
||||
function sendEmail(string $from, string $to, string $title, string $body): void
|
||||
{
|
||||
$from = str_replace(["\r", "\n", "\r\n"], '', $from);
|
||||
$to = str_replace(["\r", "\n", "\r\n"], '', $to);
|
||||
$title = str_replace(["\r", "\n", "\r\n"], '', $title);
|
||||
|
||||
$header = "From: $from\r\n";
|
||||
$header .= "Reply-To: $from\r\n";
|
||||
$header .= "MIME-Version: 1.0\r\n";
|
||||
$header .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||
|
||||
mail($to, $title, $body, $header);
|
||||
}
|
||||
|
||||
// ── رفع صورة آمن ──────────────────────────────────────────────
|
||||
function uploadImageSecure(
|
||||
string $fileKey,
|
||||
string $targetDir,
|
||||
string $prefix = '',
|
||||
array $allowedMimes = ['image/jpeg', 'image/png', 'image/webp']
|
||||
): array {
|
||||
if (!isset($_FILES[$fileKey]) || $_FILES[$fileKey]['error'] !== UPLOAD_ERR_OK) {
|
||||
return ['success' => false, 'error' => 'File upload error'];
|
||||
}
|
||||
|
||||
$file = $_FILES[$fileKey];
|
||||
$maxSize = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
// حجم الملف
|
||||
if ($file['size'] > $maxSize) {
|
||||
return ['success' => false, 'error' => 'File too large (max 5MB)'];
|
||||
}
|
||||
|
||||
// MIME validation حقيقي (ليس extension فقط)
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($file['tmp_name']);
|
||||
|
||||
if (!in_array($mimeType, $allowedMimes, true)) {
|
||||
return ['success' => false, 'error' => "Invalid file type: $mimeType"];
|
||||
}
|
||||
|
||||
// اسم ملف آمن وعشوائي
|
||||
$ext = match ($mimeType) {
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
default => 'bin',
|
||||
};
|
||||
$filename = ($prefix ? "{$prefix}_" : '') . bin2hex(random_bytes(8)) . ".$ext";
|
||||
|
||||
if (!is_dir($targetDir)) {
|
||||
mkdir($targetDir, 0750, true);
|
||||
}
|
||||
|
||||
$targetPath = rtrim($targetDir, '/') . '/' . $filename;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
|
||||
return ['success' => false, 'error' => 'Failed to move uploaded file'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'filename' => $filename, 'path' => $targetPath];
|
||||
}
|
||||
|
||||
// ── تحميل ملف .env ───────────────────────────────────────────
|
||||
function loadEnvironment(string $path): void
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
error_log("[ENV] File not found: $path");
|
||||
return;
|
||||
}
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (str_starts_with(trim($line), '#')) continue;
|
||||
if (!str_contains($line, '=')) continue;
|
||||
[$key, $value] = explode('=', $line, 2);
|
||||
$key = trim($key);
|
||||
$value = trim($value, " \t\n\r\0\x0B\"'");
|
||||
if ($key && !getenv($key)) {
|
||||
putenv("$key=$value");
|
||||
$_ENV[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Logging منظم ──────────────────────────────────────────────
|
||||
function securityLog(string $message, array $context = []): void
|
||||
{
|
||||
$logDir = __DIR__ . '/../logs';
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0750, true);
|
||||
}
|
||||
$entry = date('Y-m-d H:i:s') . ' [SECURITY] ' . $message;
|
||||
if ($context) $entry .= ' | ' . json_encode($context, JSON_UNESCAPED_UNICODE);
|
||||
@error_log($entry . PHP_EOL, 3, $logDir . '/security.log');
|
||||
}
|
||||
|
||||
function appLog(string $message, string $level = 'INFO'): void
|
||||
{
|
||||
$logDir = __DIR__ . '/../logs';
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0750, true);
|
||||
}
|
||||
$entry = date('Y-m-d H:i:s') . " [$level] " . $message;
|
||||
@error_log($entry . PHP_EOL, 3, $logDir . '/app.log');
|
||||
}
|
||||
|
||||
function uploadLog(string $message, string $level = 'INFO', array $context = []): void
|
||||
{
|
||||
$logDir = __DIR__ . '/../logs';
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0750, true);
|
||||
}
|
||||
|
||||
if (!isset($context['ip'])) {
|
||||
$context['ip'] = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
}
|
||||
if (!isset($context['user_agent'])) {
|
||||
$context['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown';
|
||||
}
|
||||
|
||||
if (isset($context['upload_error_code'])) {
|
||||
$errCode = $context['upload_error_code'];
|
||||
$context['upload_error_desc'] = match ($errCode) {
|
||||
UPLOAD_ERR_OK => 'UPLOAD_ERR_OK (0): No error, file uploaded successfully.',
|
||||
UPLOAD_ERR_INI_SIZE => 'UPLOAD_ERR_INI_SIZE (1): The uploaded file exceeds the upload_max_filesize directive in php.ini.',
|
||||
UPLOAD_ERR_FORM_SIZE => 'UPLOAD_ERR_FORM_SIZE (2): The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.',
|
||||
UPLOAD_ERR_PARTIAL => 'UPLOAD_ERR_PARTIAL (3): The uploaded file was only partially uploaded (common on weak/3G networks).',
|
||||
UPLOAD_ERR_NO_FILE => 'UPLOAD_ERR_NO_FILE (4): No file was uploaded.',
|
||||
UPLOAD_ERR_NO_TMP_DIR => 'UPLOAD_ERR_NO_TMP_DIR (6): Missing a temporary folder.',
|
||||
UPLOAD_ERR_CANT_WRITE => 'UPLOAD_ERR_CANT_WRITE (7): Failed to write file to disk.',
|
||||
UPLOAD_ERR_EXTENSION => 'UPLOAD_ERR_EXTENSION (8): A PHP extension stopped the file upload.',
|
||||
default => "Unknown upload error code: $errCode",
|
||||
};
|
||||
}
|
||||
|
||||
$entry = date('Y-m-d H:i:s') . " [$level] " . $message;
|
||||
if ($context) {
|
||||
$entry .= ' | ' . json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
@error_log($entry . PHP_EOL, 3, $logDir . '/upload.log');
|
||||
}
|
||||
|
||||
function debugLog(string $message): void
|
||||
{
|
||||
appLog($message, 'DEBUG');
|
||||
}
|
||||
|
||||
function getInternalSocketKey(): string
|
||||
{
|
||||
$key = getenv('INTERNAL_SOCKET_KEY');
|
||||
if ($key) {
|
||||
return trim($key);
|
||||
}
|
||||
$path = getenv('INTERNAL_SOCKET_KEY_PATH') ?: '';
|
||||
if (file_exists($path)) {
|
||||
return trim((string)@file_get_contents($path));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* تطبيع رقم الهاتف إلى الصيغة الدولية بدون + (E.164 بدون +)
|
||||
* ناتج ثابت لأي مدخل من JO/SY/EG:
|
||||
* الأردن → 9627XXXXXXXX
|
||||
* سوريا → 9639XXXXXXX
|
||||
* مصر → 20XXXXXXXXXX
|
||||
* يُستخدم دائماً قبل التشفير وقبل الاستعلام.
|
||||
*/
|
||||
function normalizePhone(string $phone): string
|
||||
{
|
||||
$d = preg_replace('/\D+/', '', $phone);
|
||||
|
||||
// سوريا: 09X → 963X | 9X (9 أرقام) → 963X | 963... مكتمل
|
||||
if (strlen($d) === 10 && str_starts_with($d, '09')) return '963' . substr($d, 1);
|
||||
if (strlen($d) === 9 && str_starts_with($d, '9')) return '963' . $d;
|
||||
if (strlen($d) === 12 && str_starts_with($d, '963')) return $d;
|
||||
|
||||
// الأردن: 07X → 962X | 7X (9 أرقام) → 962X | 962... مكتمل
|
||||
if (strlen($d) === 10 && str_starts_with($d, '07')) return '962' . substr($d, 1);
|
||||
if (strlen($d) === 9 && str_starts_with($d, '7')) return '962' . $d;
|
||||
if (strlen($d) === 12 && str_starts_with($d, '962')) return $d;
|
||||
|
||||
// مصر: 01X → 20X | 201... مكتمل
|
||||
if (strlen($d) === 11 && str_starts_with($d, '01')) return '20' . substr($d, 1);
|
||||
if (strlen($d) === 13 && str_starts_with($d, '20')) return $d;
|
||||
|
||||
return $d; // رقم خارج النطاق — يُعاد كما هو
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// osrm_routing.php
|
||||
// Helper functions for OpenStreetMap Routing Machine (OSRM)
|
||||
// Used to estimate accurate distance (km) and duration (mins)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get Distance and Duration between two coordinates using public OSRM server.
|
||||
* Note: If you host your own OSRM, replace the $osrmBaseUrl.
|
||||
*
|
||||
* @param float $startLat
|
||||
* @param float $startLng
|
||||
* @param float $endLat
|
||||
* @param float $endLng
|
||||
* @param string $countryCode (e.g. 'JO', 'SY', 'EG')
|
||||
* @return array|null Returns ['distance_km' => float, 'duration_min' => float] or null on failure.
|
||||
*/
|
||||
function getOsrmRouteDetails($startLat, $startLng, $endLat, $endLng, $countryCode = 'JO') {
|
||||
// Intaleq Maps SaaS server handles all countries (Jordan, Syria, Egypt)
|
||||
$baseUrl = "https://map-saas.intaleqapp.com/api/maps/route";
|
||||
|
||||
$queryParams = http_build_query([
|
||||
'fromLat' => $startLat,
|
||||
'fromLng' => $startLng,
|
||||
'toLat' => $endLat,
|
||||
'toLng' => $endLng
|
||||
]);
|
||||
|
||||
$url = "{$baseUrl}?{$queryParams}";
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 5); // 5 seconds timeout
|
||||
|
||||
// Add Intaleq API Key Header
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
"x-api-key: in_9478b32836d19cff73db3063"
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200 && $response) {
|
||||
$data = json_decode($response, true);
|
||||
if (isset($data['distance']) && isset($data['duration'])) {
|
||||
$distanceMeters = (float)$data['distance'];
|
||||
$durationSeconds = isset($data['trafficAwareDuration']) ? (float)$data['trafficAwareDuration'] : (float)$data['duration'];
|
||||
|
||||
return [
|
||||
'distance_km' => round($distanceMeters / 1000, 2),
|
||||
'duration_min' => round($durationSeconds / 60, 2)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user