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:
Hamza-Ayed
2026-07-27 05:14:13 +03:00
co-authored by Claude Opus 5
parent 9909d9b4c1
commit 4d8414c96b
3198 changed files with 766859 additions and 0 deletions
+328
View File
@@ -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;
}
}
+134
View File
@@ -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);
}
}
+85
View File
@@ -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() {}
}
+101
View File
@@ -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);
}
}
+141
View File
@@ -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);
}
}
+248
View File
@@ -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);
}
}
?>
+78
View File
@@ -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}");
}
}
+379
View File
@@ -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;
}
}
+196
View File
@@ -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);
+290
View File
@@ -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; // رقم خارج النطاق — يُعاد كما هو
}
+60
View File
@@ -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;
}