Compare commits

Author SHA1 Message Date
Hamza-Ayed 4620e84d34 Update: 2026-08-02 17:52:28 2026-08-02 17:52:28 +03:00
Hamza-Ayed b78a6797d5 Fix table name click to cliq in all files 2026-08-01 04:31:53 +03:00
Hamza-Ayed 811b23ca9e Fix JSON payload parsing in payment server 2026-08-01 04:23:30 +03:00
Hamza-Ayed 915d517ba7 Port all fixes from IntaleqApp (Wallet, CLIQ, OTP, Docker) 2026-08-01 03:58:37 +03:00
Hamza-Ayed 12fc64dc9a Update: 2026-08-01 02:06:59 2026-08-01 02:06:59 +03:00
Hamza-Ayed 46e74330a1 Update: 2026-08-01 01:46:21 2026-08-01 01:46:21 +03:00
Hamza-Ayed 410fb14d77 Update: 2026-07-31 19:21:38 2026-07-31 19:21:38 +03:00
Hamza-Ayed cf3fea3834 Update: 2026-07-31 19:18:42 2026-07-31 19:18:43 +03:00
Hamza-Ayed 0452c36379 Update: 2026-07-31 19:14:05 2026-07-31 19:14:05 +03:00
Hamza-Ayed 7362f1ce96 Update: 2026-07-31 03:58:40 2026-07-31 03:58:40 +03:00
Hamza-Ayed 8d67530b44 Update: 2026-07-31 03:06:59 2026-07-31 03:06:59 +03:00
Hamza-Ayed f94a9478ac Update: 2026-07-31 03:00:06 2026-07-31 03:00:06 +03:00
Hamza-Ayed 7a576b7327 Update: 2026-07-30 12:31:26 2026-07-30 12:31:27 +03:00
Hamza-AyedandClaude Opus 5 ecfe756849 fix(bots): توجيه السوشال بوت لنقطة النهاية الصحيحة — التعليق العضوي كان ميتاً
السبب الجذري: BASE_URL واحد يشير إلى marketing_engine/index.php، بينما
`process_organic_post` و`log` موجودان في social_worker.php وحده. فكانا
يقعان على `default` فيعود {"status":"error"} بكود HTTP 200 — بلا استثناء
ولا سجل. أثره: processOrganicPost يعود null فيفشل شرط generated_comment في
FacebookBotService:137 → لا تعليق عضوي يُنشر أبداً، وكل logMessage يُبتلع
صامتاً فلم يظهر العطل في أي سجل. (تعليق العميل سطر 10 كان يذكر
social_worker.php أصلاً — الرابط هو ما خالف النية.)

- توجيه لكل أمر حسب موضعه: index.php لـ get_task/complete_task/fail_task/
  evaluate_posts · social_worker.php لـ process_organic_post/log
- evaluate_posts: index.php يرد بلا حقل `data`، والعميل كان يشترطه فيعود
  null في كل نجاح. النجاح صار يُقاس بـ status
- ترميز URL لكل المعاملات: رسالة خطأ فيها & أو # كانت تشقّ جسم
  x-www-form-urlencoded وتفسد صفّ المهمة
- كل رد غير 200 أو status != success يُسجَّل في Logcat بدل الصمت
- المضيف إلى BuildConfig.BACKEND_HOST في البوتين (gradle) — لا نطاق مشفّر
  في الكود، فتبديله لأي علامة أخرى سطر واحد
- val لا const val لقيم BuildConfig (ليست ثوابت وقت ترجمة في كوتلن)

لم يُبنَ بعد. لم يُمسّ أي PHP: الباك إند حيّ.
ما زال مفتوحاً بقرار المالك: (1) فحص X-Bot-Token معطَّل بتعليق في
social_worker.php:14 و index.php:17 والتوكن placeholder → النقطتان
مفتوحتان؛ (2) طابور social_tasks الذي يغذّيه schedule_manager بلا مستهلك.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 05:06:08 +03:00
Hamza-Ayed 4d2beaae5e chore: update Flutter SDK paths and project dependencies across packages 2026-07-27 04:56:25 +03:00
Hamza-AyedandClaude Opus 5 b84e26fe9a تسجيل نتيجة send_fcm.php — مسار الرسائل كان صندوقاً أسود بالكامل
send_fcm.php هي نقطة كل الرسائل والمكالمات بين الراكب والسائق (التطبيقان
يستدعيانها مباشرة)، ولم تكن تحتوي أي error_log إطلاقاً — بخلاف FcmService
التي تطبع [FCM_DEBUG] وتخدم دورة حياة الرحلة فقط.

النتيجة أن فشل الرسائل كان غير قابل للتشخيص: لا سبب، ولا رد Google، ولا
حتى معرفة إن كان الطلب وصل. وقد وعدت المستخدم بقراءة [FCM_DEBUG] لهذا
المسار وهو وعد خاطئ — لا وجود له هنا.

أُضيفت ثلاث نقاط:
- نتيجة الإرسال: category + طرف من التوكن + http + طول التوكن + رد Google
  عند الفشل. طول التوكن مقصود: توكن FCM الحقيقي ~163 محرفاً، والمشفّر في
  جدول tokens 216 — فيُكشف أي blob مشفّر من سطر واحد.
- رفض 403: التطبيقان لا يرسلان x-api-key، فلحظة ضبط FCM_INTERNAL_API_KEY
  تموت كل الرسائل بينهما بصمت. الآن يُسجَّل السبب صريحاً.
- رفض 400 على target فارغ: يعني أن المُرسِل لا يملك توكن الطرف الآخر
  (tokenPassenger أو driverToken لم يصله في حمولة القبول).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:35:01 +03:00
Hamza-AyedandClaude Opus 5 77c0b48ec2 إعادة تفعيل access_log في nginx — كان مطفأً فأفسد كل تشخيص
docker/nginx/default.conf:13 كان فيه:
    access_log off;   # أثناء اختبار الضغط؛ فعّله عند الحاجة
مُطفأً من اختبار ضغط قديم ونُسي.

أثره أن أي بحث في سجلّ الوصول يرجع صفراً دائماً:
    docker compose logs nginx | grep -c "send_fcm"   →  0
فيبدو الاستنتاج أن التطبيق لا يستدعي المسار إطلاقاً، والحقيقة أن السجلّ
فارغ لا أن الطلبات غائبة. استنتاج خاطئ تماماً بُني على قياس معطّل.

مع توجيه error_log في www-pool.conf (الالتزام e106d2df) تكتمل الرؤية:
nginx يقول هل وصل الطلب، وPHP يقول ماذا ردّ Google.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:31:56 +03:00
Hamza-AyedandClaude Opus 5 e106d2df4e توجيه error_log لمخرج الحاوية — كنا نشخّص الأعطال عمياناً
www-pool.conf كان يوجّه slowlog إلى /proc/self/fd/2 لكن لا شيء لـ error_log،
فكل رسائل error_log() في الباك إند تُبتلع ولا يظهر أي سطر في:
    docker compose logs php

والباك إند مليء بها ويعتمد عليها في التشخيص: [accept_ride] و [FCM_DEBUG]
(يطبع حمولة FCM ورد Google كاملاً) و 📲 [FCM_RESULT] و [SOCKET_DEBUG] و
[SSRF_BLOCKED] و [SOCKET_BLOCKED].

النتيجة العملية أن سبب فشل الإشعارات كان يمكن قراءته في سطر واحد من رد
Google (UNREGISTERED / INVALID_ARGUMENT / 403) بدل الاستنتاج من الكود.

catch_workers_output=yes ينقل stdout/stderr للعمّال، و decorate_workers_output=no
يمنع بادئات الضجيج، وphp_admin_value[error_log] يثبّت المسار على fd/2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:30:03 +03:00
Hamza-AyedandClaude Opus 5 899a18b52d حرس السوكيتات المكرّرة للسائق — سبب «notified 0/0» وتحذيرات null
في لوج الإنتاج ثلاثة اتصالات متزامنة للسائق نفسه (التطبيق يفتح سوكيتاً من
location_controller وآخر من background_service ومحاولات لم تُنظَّف):
    ✅ Driver Connected: #243ea… (×3)

ومعالج disconnect كان ينفّذ unset($connectedDrivers[$driverId]) بلا تحقّق من
أيّ سوكيت أُغلق. فإغلاق نسخة واحدة كان يُطفئ السائق فعلياً وهو متصل:

1) dispatch_order يراه offline فيضع الطلب في طابور الانتظار بدل إرساله،
   ويظهر في اللوج:  🚫 Ride #2569 cancelled — notified 0/0 driver(s).
2) update_location من سوكيت ما زال حياً يجد $driverState[$driverId] محذوفاً،
   و &$driverState[...] يُنشئ مدخلاً null فتُقرأ منه كل الحسابات:
     Warning: Trying to access array offset on value of type null (860-872)
     Warning: Undefined array key "status" (907)

الإصلاحان:
- نحفظ sid (معرّف السوكيت) مع السجلّ، ولا ننظّف في disconnect إلا إذا كان
  المُغلَق هو المسجَّل حالياً؛ وإلا نتركه ونسجّل «stale duplicate».
- حرس على $driverState في update_location: يُعاد بناؤه إن غاب لأي سبب
  (إغلاق نسخة أخرى، إعادة تشغيل) بدل القراءة من null.

سيرفر فقط — لا يحتاج بناء التطبيقات. يبقى فتح التطبيق لعدة سوكيتات هدراً
في الحركة يستحق إصلاحاً في الطرف الآخر لاحقاً.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:26:20 +03:00
Hamza-AyedandClaude Opus 5 e6504acdea إرسال platform في مصافحة سوكيت السائق — كباتن iOS كانوا يفوتهم الطلب
driver_socket.php عند توزيع الطلب (dispatch_order) يرسل إشعار FCM احتياطياً
**فقط** إذا كانت المنصّة ios، لأن iOS يخنق أحداث السوكيت في الخلفية:

    $platform = $connectedDrivers[$driverId]['platform'] ?? 'android';
    if ($platform === 'ios' && !empty($token)) { sendFCM_Async(...) }

وتطبيق السائق لم يكن يرسل platform في الـ query إطلاقاً، فالافتراضي
'android' — أي أن كابتن الآيفون لا يستلم الإشعار الاحتياطي ويفوته الطلب وهو
في الخلفية، ويعتمد على السوكيت وحده وهو مخنوق أصلاً.

ظهر بالعين في لوج الإنتاج على جهاز آيفون:
    ✅ Driver Connected: #243ea897954387c9 (android)

أُضيف الحقل في موضعي الاتصال: location_controller و background_service.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:20:52 +03:00
Hamza-AyedandClaude Opus 5 ef1240b130 تصحيح مفتاح Redis لموقع السائق: driver:location ← driver:public
الموضعان كانا يقرآن مفتاحاً لا يُكتب في أي مكان في المشروع:
  $redisLocation->hGetAll("driver:location:$driverId")

الكاتب الفعلي هو معالج الدفعات في loction_server/driver_socket.php، وهو
يكتب hmset على driver:profile:{id} و driver:public:{id} معاً بنفس الحقول
(lat/lng/heading/speed/status/updated_at). و driver:public له TTL 86400
بينما driver:profile له 900 فقط، فالعام هو الأنسب للقراءة.

الأثر: كانت حمولة القبول تصل الراكب بلا إحداثيات أولية للسائق، فلا يظهر
الماركر إلا بعد أول تحديث موقع من السوكيت أو الـ polling.

أحد الموضعين ملف getRideOrderID.php الذي أضفته في f66db7db — نسخت النمط
من acceptRide.php فنقلت الخطأ معه.

مثبَّت على الإنتاج: redis-cli --scan --pattern 'driver:*' أرجع
driver:public:<id> فقط، ولا شيء باسم driver:location.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:14:26 +03:00
Hamza-AyedandClaude Opus 5 0e6dd68385 حذف http2 من إعداد سوكيتات TLS — nginx المضيف أقدم من 1.25.1
nginx على السيرفر يرفض التحميل:
  nginx: [emerg] unknown directive "http2" in siro-sockets-tls.conf:30

التوجيه http2 on; لم يُضَف إلا في nginx 1.25.1. والحذف هو الصحيح لا مجرد
حلٍّ سريع: ترقية WebSocket تعتمد على ترويسة Upgrade في HTTP/1.1 ولا معنى
لها في HTTP/2 أصلاً (وproxy_http_version 1.1 مضبوط في الـ location).

تحذير تشغيلي: بعد up --force-recreate انتقلت الحاويتان إلى 127.0.0.1:12020
و13030، فإن فشل nginx -t يبقى المنفذان 2020/3030 بلا أي مستمع (Connection
refused) حتى ينجح reload. رتّب النشر: صحّح الإعداد ⇒ nginx -t ⇒ reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:04:32 +03:00
Hamza-AyedandClaude Opus 5 afb5189515 إنهاء TLS أمام سوكيتات Workerman — إصلاح Socket Connect Error: timeout
السبب (مثبَّت على الإنتاج): التزام c35b350b بتاريخ 2026-07-23 — وهو التزام
تلقائي برسالة "Update: <time>" — أضاف المنفذ للروابط:
  -  'https://jordan-siro.intaleqapp.com'
  +  'https://jordan-siro.intaleqapp.com:2020'
وكانت القيمة العاملة قبل ذلك بلا منفذ (443) فيُنهي البروكسي TLS ويمرّر.

بإضافة :2020 صار التطبيق يضرب حاوية Workerman مباشرة، وهي تفتح المنفذ نصاً
صريحاً (new SocketIO(2020) بلا سياق SSL)، فمصافحة TLS تتجمّد حتى المهلة:
  ❌ Socket Connect Error: timeout
  ❌ Socket Connect Timeout: 20000
ونتيجته أن السائق لا يبعث update_location إطلاقاً ⇒ لا GPS في Redis ⇒ لا
موقع للراكب لا عبر السوكيت ولا عبر الـ polling (كلاهما يقرأ من نفس المصدر).

مثبَّت بالأدلة على السيرفر:
- ss: المنفذان 2020/3030 يملكهما docker-proxy (نص صريح)، و443 nginx.
- curl على 127.0.0.1:2020 يرد {"sid":...,"upgrades":["websocket"]} — أي أن
  الحاوية سليمة تماماً والناقص هو طبقة TLS وحدها.

الحل بلا أي بناء للتطبيقات (وبلا تنزيل مستوى الأمان — الـ JWT يُرسَل في
query string فلا يجوز إطلاقاً تحويله إلى http):
- الحاويتان تُنشران على 127.0.0.1:12020 و 127.0.0.1:13030 فقط.
- nginx على المضيف يستمع على 2020/3030 بشهادة الدومين ويمرّر إليهما مع
  ترقية WebSocket (nginx/siro-sockets-tls.conf).
- proxy_read/send_timeout 3600s: اتصال السائق يعيش ساعات، وبلا ذلك يقطعه
  nginx كل 60ث فتدخل الحاوية حلقة إعادة اتصال دائمة.
- proxy_buffering off و access_log off (نبضات GPS تُغرق القرص).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:01:42 +03:00
Hamza-AyedandClaude Opus 5 6fec88251c إصلاح أخطاء شاشة الكباتن من تحديث الذكاء الاصطناعي
استرجاع شبكة أمان موقع السائق (انحدارة من الالتزام ab3be7e2):
- كان شرط "Smart Mode" يتخطّى _startDriverLocationPollingWithTimer كلياً إذا
  جاء القبول من السوكيت. قبل إصلاح ترتيب الاتصال كان السوكيت يخسر دائماً
  فالـ polling يعمل ويحمل الموقع؛ وبعد أن صار يفوز تعطّلت شبكة الأمان.
- وصول حدث القبول لا يعني أن تدفّق المواقع يعمل: التدفّق له شرط مستقل —
  تطبيق السائق يحقن passenger_id في update_location فقط إذا كان rideStatus
  نشطاً و BoxName.passengerID مكتوباً، وهو لا يُكتب إلا بعد فتح صفحة خريطة
  السائق (map_driver_controller.dart:2444).
- الآن الـ polling يعمل دائماً، و handleDriverLocationUpdate يوقفه تلقائياً
  بعد 3 تحديثات ناجحة من السوكيت — وهذا المنطق كان موجوداً أصلاً وهو التصميم
  المقصود. السوكيت يبقى المسار السريع بلا فقدان شبكة الأمان.

عدم تعطيل إنشاء الرحلة بانتظار سوكيت غير قابل للوصول:
- ensureConnectedBeforeRide كانت تنتظر 4 ثوانٍ قبل كل طلب. سوكيتات
  Workerman تستمع نصّاً صريحاً على 2020/3030 بينما التطبيقان يستخدمان
  https:// عليها (nginx لا يمرّرها — انظر docker/nginx/default.conf)، فمصافحة
  TLS ضد منفذ غير TLS تتجمّد حتى المهلة ⇒ 4 ثوانٍ مهدورة في كل رحلة.
- المهلة صارت 2ث، وأُضيف cooldown دقيقتين: بعد فشل قريب لا ننتظر إطلاقاً بل
  نحاول الاتصال في الخلفية ونكمل الطلب فوراً.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:51:18 +03:00
Hamza-Ayed b76eccb763 إصلاح أخطاء شاشة الكباتن من تحديث الذكاء الاصطناعي 2026-07-27 00:25:02 +03:00
Hamza-AyedandClaude Opus 5 a26472ed9f إصلاح مسار الإشعار→الطلب عند الكابتن + توحيد إعادة اتصال سوكيت الراكب
سوكيت الراكب:
- setReconnectionAttempts: من 20 إلى لا نهائي، توحيداً مع تطبيق السائق. بعد
  20 محاولة كان يستسلم **نهائياً** فيصمت للأبد بعد دقائق من شبكة سيئة ويبقى
  الراكب على الـ polling بلا أن يدري — وخطره أكبر الآن لأن الاتصال صار يعيش
  من فتح الخريطة لا من إنشاء الرحلة.
- الـ heartbeat من 15ث إلى 30ث: Socket.IO يعمل ping/pong على مستوى
  البروتوكول ومستمع 'heartbeat' في passenger_socket.php فارغ أصلاً.

البدء البارد على iOS (سبب "التطبيق يغلق ونرجع من أول"):
- didChangeAppLifecycleState لا يُنفَّذ عند إطلاق التطبيق (يبدأ في resumed
  بلا انتقال حالة)، وهو المُنادي الوحيد لـ _checkPendingTrip ⇒ الضغط على
  الإشعار والتطبيق مقتول لا يوجّه لصفحة الطلب إطلاقاً.
- وحتى لو نُفِّذ، كان يستسلم على '/' ويحوّل المسؤولية لـ
  HomeCaptainController الذي لا يملك أي منطق كهذا ⇒ الطلب يبقى في التخزين
  للأبد. صار addPostFrameCallback + مُجدوِل يعيد المحاولة حتى خروج التطبيق
  من شاشة البداية (سقف ~30ث)، ولا يحذف الطلب قبل التوجيه.
- حرس إضافي: لا اقتحام لرحلة نشطة بطلب قديم، لا توجيه مكرر إذا كنا على
  OrderRequestPage، وتنظيف الحمولة التالفة.

TripOverlayPlugin أندرويد فقط بلا أي حرس منصّة:
- كل دالة كانت تنادي invokeMethod مباشرة ⇒ MissingPluginException على iOS
  (لا يوجد ios/ ولا مدخل في pubspec.plugin.platforms).
- الأخطر: backgroundMessageHandler كان ينادي showOverlay **قبل** حفظ
  pending_driver_list، فيسقط الاستثناء ويُلغي الحفظ كلياً على iOS. عُكس
  الترتيب — الحفظ أولاً دائماً، وهو الحدّ الأدنى الذي لا يتوقف على منصّة أو
  صلاحية.
- أُضيف isSupported وحُصِّنت كل الدوال (ترجع false/no-op ولا ترمي أبداً)،
  فلا يُقطع أيضاً _initApp قبل _listenToOverlayEvents.

أخطاء index في حمولة add_ride.php (أربع نقاط قبول):
- 'Duration' كان index 4 وهو end_lng لا المدة ⇒ صار 15 (duration_text).
  أحد المواضع كان فيه تعليق "انتبه: تأكد من الإندكس الصحيح للوقت".
- 'passengerWalletBurc' كان 26 وهو price_for_driver (أرباح السائق) لا رصيد
  الراكب ⇒ صار 27 (passenger_wallet).
- الموضعان مكرران في main.dart و local_notification.dart و order_over_lay.dart
  و order_request_controller.dart — وُحِّدت كلها.
- حرس الطول في backgroundMessageHandler: كان length > 29 مع قراءة index 30
  ⇒ RangeError على قائمة طولها 30 بالضبط.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:07:27 +03:00
Hamza-AyedandClaude Opus 5 ab3be7e2e3 وصل سوكيت الراكب قبل إنشاء الرحلة — ليسبق FCM والـ polling
كان السوكيت مضموناً أن يخسر السباق الأول بنيوياً. ترتيب
startSearchingForDriver كان:
  ① await postRideDetailsToServer()  → add_ride.php يوزّع الطلب على الكباتن فوراً
  ② _addRideToWaitingTable()          (بلا await)
  ③ initConnectionWithSocket()        ← السوكيت يبدأ الاتصال الآن فقط

بين ① و③ نافذة = ذهاب/عودة HTTP + مصافحة WebSocket + قراءة الـ JWT، ثانية
إلى ثلاث (أسوأ على 3G). وغرف Socket.IO لا تخزّن الأحداث: لو قبل كابتن داخل
تلك النافذة فإن $io->to('passenger_X')->emit() يُرمى نهائياً لأن الغرفة
فارغة — لا طابور ولا إعادة إرسال. وكابتن ينظر لقائمة السوق يقبل في أقل من
ثانية، فالنافذة مُرجَّحة لا نادرة.

هذا يفسّر لماذا كان الـ 404 على getRideOrderID.php قاتلاً: السوكيت يخسر،
والـ FCM يصل بعد أن ضبط الـ polling _isAcceptanceProcessed=true فيُرفض،
والشبكة الاحتياطية الوحيدة مكسورة.

ترتيب السيرفر كان صحيحاً أصلاً (acceptRide.php ينادي
notifyPassengerOnRideServer قبل sendFCM_Internal) — المشكلة أن المستقبِل
لم يكن موجوداً بعد.

التغييرات:
- ensureConnectedBeforeRide(): يوصل وينتظر الانضمام للغرفة بسقف 4 ثوانٍ،
  ولا يُفشل إنشاء الرحلة عند انتهاء المهلة (نكمل ونتّكل على FCM/polling).
- startSearchingForDriver ينتظرها قبل postRideDetailsToServer.
- وصل السوكيت في initializeDataAfterLogin (عند فتح الخريطة) فيصير الانتظار
  صفراً عملياً في الحالة الطبيعية.
- _isConnecting يغطّي نافذة الـ await على الـ JWT حتى لا يفتح النداءان
  المتزامنان سوكيتين، وحرس على passengerID الفارغ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:26:23 +03:00
Hamza-AyedandClaude Opus 5 f66db7db42 إضافة getRideOrderID.php المفقود — المسار الاحتياطي لبيانات السائق
تطبيق الراكب يطلب /ride/rides/getRideOrderID.php منذ البداية، والموجود على
السيرفر getRideOrderIDNew.php فقط ⇒ 404 صامت في getUpdatedRideForDriverApply.

هذا يفسّر عرضين ظنّاهما منفصلين:
- الـ polling يسبق الـ FCM فيضبط _isAcceptanceProcessed=true ثم يأخذ 404،
  فيُرفض بعدها payload الـ FCM الذي يحمل driver_info كاملاً
  ("Already processed") ⇒ الرحلة مقبولة بلا معلومات سائق.
- ولأن driverToken يبقى فارغاً، ترد send_fcm.php بـ 400 Missing: target
  ⇒ رسائل الراكب للسائق لا تُرسَل إطلاقاً. (الاتجاه المعاكس كان يعمل بعد
  إصلاح getRideWaiting.php — نفس خطأ التشفير معكوساً.)

getRideOrderIDNew.php لا يصلح بديلاً: داخلي عبر get_connect.php →
validateInternalKey فلا يستطيع التطبيق مناداته، ولا يرجع
ratingCount/completedRides/driverTier.

النقطة الجديدة:
- connect.php (JWT)، والراكب من الـ JWT فقط لا من الطلب (حماية IDOR).
- نفس استعلام acceptRide.php وشكل رده حرفياً حتى يقرأه
  _fillDriverDataLocally بنفس المفاتيح.
- فك تشفير driverToken.token — بلا ذلك يصل التطبيق blob يستخدمه كـ FCM
  target فيرفضه FCM بـ 400.
- تفادي تصادم المفاتيح: getUpdatedRideForDriverApply يقرأ
  passengerName + last_name كاسم الراكب، فلقب السائق نُقل إلى
  driver_last_name واسم الراكب الحقيقي يُرجَّع في مكانه.
- ride DB هو المرجع مع fallback على primary، ورحلة بلا سائق ترد success
  لا failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:18:02 +03:00
Hamza-AyedandClaude Opus 5 143146c1b4 إصلاح سوكيت الراكب وتوحيد مسارات الإشعارات
سوكيت الراكب (سبب عدم ظهور معلومات السائق عند القبول):
- تطبيق الراكب كان يرسل id فقط بلا jwt، و passenger_socket.php يرفض أي
  اتصال بلا jwt ⇒ الراكب لا ينضم لغرفته أبداً ولا يستلم ride_status_change
  ولا driver_location_update. تظهر حالة القبول عبر الـ polling فقط بينما
  driver_info يصل بالسوكيت وحده. (سوكيت السائق يعتبر الـ jwt اختيارياً،
  ومن هنا جاء التباين بين التطبيقين.)
- cancelled_by_driver كان يسقط من switch حالات الراكب فيبقى معلّقاً بعد
  إلغاء السائق.
- حماية socket (late) من القراءة قبل التهيئة عند الانسحاب بلا jwt.

الإشعارات والرسائل (سبب "مرات توصل ومرات لا"):
- جدول tokens يخزّن توكن الراكب مشفّراً، و getRideWaiting.php كان يرجعه
  بلا فك تشفير ⇒ من يقبل من قائمة السوق يحمل blob مشفّراً يستخدمه كـ FCM
  target فيرفضه FCM بـ 400: لا إشعار قبول ولا رسائل. ومن يقبل من الـ
  dispatch/FCM يحمل نصاً صريحاً فتعمل. الفرق كان في طريقة القبول.
- acceptRide.php يحلّ التوكن من القاعدة دائماً ولا يثق بالعميل (أصحّ أمنياً).
- market_new_ride كان لا يحمل passengerId ولا الإحداثيات فتصل "null"؛
  أُضيفت بلا أي PII لأن الحمولة تُبَثّ لكل سائق قريب لا للفائز فقط.
- send_fcm.php: مهلة على OAuth (كان يعلّق حتى مهلة PHP فتُسقط الرسالة
  بصمت)، توحيد ding→default لأندرويد، وحقن title/body/tone في data
  مطابقةً لـ FcmService.
- تطبيق السائق يقرأ title/body من data أولاً مثل الراكب، ولا يعرض فقاعة
  فارغة للرسائل الصامتة.

السوكيت والإعدادات:
- forwardLocationToPassengerSocket كان يقرأ lat/lng والحمولة فيها
  latitude/longitude ⇒ المسافة تخرج ضخمة والـ throttle معطّل تماماً
  فيُعاد التوجيه مع كل نبضة GPS.
- notifyPassengerOnRideServer كان يرجع null بصمت مطلق عند حجب العنوان.
- العنوان الافتراضي لسيرفر الموقع كان nginx/loction_server/driver_socket.php
  وهو ديمون Workerman لا يُخدَم عبر nginx ⇒ صار socket_driver:2021.
  (LOCATION_API_URL بقي على nginx لأن api_get_nearby.php سكربت عادي.)
- ride_server/passenger_socket.php (النسخة التي يشغّلها Docker) كانت ناقصة
  كل كود مواصلاتي الموجود في passenger_server/ ⇒ نُقل مع REDIS_HOST.
- .env.example: ALLOWED_SOCKET_URLS يغطّي أسماء حاويات Docker، وإضافة
  PASSENGER_SOCKET_INTERNAL_URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:47:23 +03:00
Hamza-Ayed e269cb9b70 إصلاح أخطاء شاشة الكباتن من تحديث الذكاء الاصطناعي 2026-07-26 13:44:07 +03:00
Hamza-Ayed 330dfc6dba إصلاح أخطاء شاشة الكباتن من تحديث الذكاء الاصطناعي 2026-07-26 13:15:45 +03:00
Hamza-Ayed 4b5235c35c إصلاح أخطاء شاشة الكباتن من تحديث الذكاء الاصطناعي 2026-07-26 12:18:23 +03:00
Hamza-Ayed 16feb51a75 إصلاح توافق فلاتر مع Theme الجديد 2026-07-26 11:36:26 +03:00
Hamza-Ayed 3af99cc18a إصلاح توافق فلاتر مع Theme الجديد 2026-07-26 11:33:20 +03:00
Hamza-Ayed 7830efead7 إصلاح توافق فلاتر مع Theme الجديد 2026-07-26 04:28:09 +03:00
Hamza-Ayed ca6cb7a3fe تصميم عالمي جديد، وإصلاح الخرائط ومراقب السيرفرات 2026-07-26 04:21:42 +03:00
Hamza-Ayed f325ffce42 تصميم عالمي جديد، وإصلاح الخرائط ومراقب السيرفرات 2026-07-26 04:02:10 +03:00
Hamza-Ayed c43f801542 إصلاح الشاشة البيضاء ومسار base-href 2026-07-26 03:26:15 +03:00
Hamza-Ayed 2bacb1b9e1 تحديث شامل للوحة التحكم وإضافة كافة الميزات للـ WebSidebar 2026-07-26 03:22:47 +03:00
Hamza-Ayed 5ec54c03f5 تحديث شامل للوحة التحكم وإضافة كافة الميزات للـ WebSidebar 2026-07-26 02:58:59 +03:00
Hamza-Ayed a954f49307 Update: 2026-07-26 02:51:53 2026-07-26 02:51:54 +03:00
Hamza-Ayed 91fe0f78f7 Update: 2026-07-26 01:29:57 2026-07-26 01:29:57 +03:00
Hamza-Ayed 5f5b68a8cd Update: 2026-07-26 01:27:56 2026-07-26 01:27:56 +03:00
Hamza-Ayed 1664743ef9 Update: 2026-07-26 01:11:28 2026-07-26 01:11:28 +03:00
Hamza-Ayed 1dfc302a4f Update: 2026-07-26 01:03:40 2026-07-26 01:03:41 +03:00
Hamza-Ayed 76c8652bf0 Update: 2026-07-25 20:18:18 2026-07-25 20:18:18 +03:00
Hamza-AyedandClaude Opus 5 a095472f39 تتبّع composer.lock ومنع تكرار انهيار خادم الدفع
vendor/ و composer.lock كانا مستثنيين في .gitignore، فلم يصل أيٌّ منهما
إلى السيرفر. النتيجة أن realpath() في payment_server/v2/main/connect.php:3
ترجع false وينهار كل مسار /v2/main/ بـ Fatal error.

- إزالة قاعدتَي composer.lock من .gitignore (vendor/ يبقى مستثنى)
- الالتزام بملفات القفل لـ payment_server/v2 و ride_server و loction_server
- ترقية firebase/php-jwt في payment_server/v2 من ^6.11 إلى ^7.0 (v7.1.0)
  لأن 6.11.x محجوب بتحذير أمني PKSA-y2cr-5h3j-g3ys — لا تغيير في الشيفرة،
  الاستخدام محصور في JWT::encode/decode و Key وأصناف الاستثناءات
- تثبيت platform.php=8.2 ليطابق القفل صورة الحاوية لا PHP المحلي

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:11:59 +03:00
Hamza-Ayed c8a9e98ff5 Update: 2026-07-25 20:09:31 2026-07-25 20:09:31 +03:00
Hamza-Ayed e03b9c30d5 Update: 2026-07-25 18:43:44 2026-07-25 18:43:44 +03:00
Hamza-Ayed 20ea9aa12c Update: 2026-07-25 18:42:18 2026-07-25 18:42:18 +03:00
Hamza-Ayed 8d3e63d1c7 Update: 2026-07-25 18:40:38 2026-07-25 18:40:38 +03:00
Hamza-Ayed 4bbc687c15 Update: 2026-07-25 18:28:30 2026-07-25 18:28:31 +03:00
Hamza-Ayed 5e103f60f2 Update: 2026-07-25 18:19:51 2026-07-25 18:19:52 +03:00
Hamza-Ayed 27369b8ac1 Update: 2026-07-25 18:05:37 2026-07-25 18:05:37 +03:00
Hamza-AyedandClaude Opus 5 61d6380861 Fix SQL injection and stale status matching in the wallet endpoints
The three endpoints the driver app calls for its wallet built their queries by
interpolating the driver id straight into SQL. Anything the app sent went into
the statement, and these run against the payments database.

They also matched only status = 'Finished'. The current ride pipeline writes
'completed', so a driver's completed rides, pending payouts and weekly
earnings all read as zero regardless of how much they had driven — which is
what the wallet errors in the admin error log are sitting next to.

getAllPayment.php, driverStatistic.php and getCountRide.php now bind the id
and match either spelling. Verified no interpolated identifier remains and
every rewritten condition is balanced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 17:57:52 +03:00
Hamza-AyedandClaude Opus 5 3fb7bc5190 Show the build id, tame long table cells, and add purposeful motion
The build identifier now sits in the sidebar footer. "Is my deploy actually
live?" was only answerable by opening Session & Security, which is the wrong
place for the first question asked after every deploy.

Error-log rows carry stack traces and full URLs. They stretched their row far
past the viewport and pushed the other columns out of view, which is exactly
where the console is least usable. Values over 70 characters truncate and
expand on click.

Motion is limited to two things that mean something: content arriving
(staggered, so a grid resolves instead of snapping), and a fetch being in
flight — the refresh control spins and the status pill pulses, so a section
that legitimately returns nothing still shows that it tried. All of it is
disabled under prefers-reduced-motion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 17:49:48 +03:00
Hamza-Ayed f9110a7d92 Update: 2026-07-25 17:40:28 2026-07-25 17:40:29 +03:00
Hamza-AyedandClaude Opus 5 a0812dbd10 Fix broadcast delivery; make transit organisations manageable
Broadcasts never reached anyone. The internal FCM call defaulted to
127.0.0.1, which inside the php container is the php container itself — the
web server runs in a separate nginx container, reachable by service name on
the Compose network. Every send failed the curl and returned a generic 502.
The default now points at nginx, the URL is overridable via FCM_INTERNAL_URL,
and the error carries the actual reason and target instead of a bare status.

Transit organisations were a read-only count table with nothing to act on.
The module now supports the operations an admin actually needs:
- open an organisation for its counts, routes, recent trips and admins
- create one, including the founding administrator create.php requires
- edit city, contact details, contract status and trial end, with a
  confirmation when the contract changes since suspending cuts off service
- add an administrator, and enable or disable an existing one

Verified end to end against the endpoints' real payload shapes, including
that an incomplete create form is rejected before any request is sent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 17:10:51 +03:00
Hamza-AyedandClaude Opus 5 bc1b0129e8 Fix v2 analytics status matching; give Growth and Analytics real charts
Eight queries across the v2 modules counted only status = 'Finished' and so
reported zero on live data, where the current ride pipeline writes
'completed': realtime revenue for today and yesterday, financial stats,
settlements, driver scorecard, driver ranking, and both revenue queries. All
now match either spelling.

Growth and Advanced Analytics rendered through the generic shape-detecting
renderer, which produced raw tables that said little. Both now have purpose-
built views:

- Growth: totals, 30-day joins, and a two-series daily chart. growth.php only
  returns days that had signups, so the series is expanded to a continuous
  30-day axis with explicit zeros — plotting the returned rows directly would
  hide the gaps and make a quiet month look like steady growth. A caption
  states how many days actually had a signup.
- Analytics: revenue summary tiles, a daily revenue trend, and the captain
  ranking, with a note explaining that platform share is what remains after
  the captain's cut.

Null aggregates render as "—" rather than 0.00, and markers are drawn only on
days with a value so a flat zero line stays readable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 16:55:58 +03:00
Hamza-AyedandClaude Opus 5 2135edcf43 Close the remaining ciphertext joins and a SQL injection in email verification
- Customer-service notes joined to the account by comparing encrypted phone
  columns. Both notes tables now carry phone_key, written when a note is
  saved, and the three joins match on it.
- The email_verifications join was comparing a plaintext column against an
  encrypted one, so it never matched and `verified` was always NULL in both
  passenger and driver sign-in. It is now resolved in PHP against the
  decrypted address, which fixes a pre-existing bug rather than only
  preparing for GCM.
- auth/sendVerifyEmail.php built all three of its statements by interpolating
  the request values into SQL. Any caller could inject through the email or
  token field. Now parameterised.
- serviceapp/register.php duplicate detection consults the users indexes and
  writes them with the row.

Sweep confirms no join or lookup compares two encrypted columns any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 16:48:10 +03:00
Hamza-AyedandClaude Opus 5 35a66935aa Repair verification joins broken by the OTP key change; extend backfill
Storing the verification phone as a keyed HMAC fixed OTP lookups but broke
every query that joined those tables back to the account, because
phone_verification*.phone_number no longer holds the same value as
driver.phone / passengers.phone. Six joins were affected, and four of them
feed the `verified` flag that the rider and driver apps check at sign-in — so
this was already failing under the current CBC mode, not only after a switch
to GCM.

Accounts now carry phone_key, computed exactly as otpPhoneKey() does, and the
joins match on it. It is written at registration for both apps and populated
for existing rows by the backfill.

The backfill also covers the columns added for the remaining lookups:
users.email_bidx/phone_bidx and driver.national_bidx, which were migrated but
never populated, and honours a per-field prefix so phone_key reproduces
otpPhoneKey's exact output.

Insert column/value counts verified with a paren-aware parser after editing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 16:40:53 +03:00
Hamza-AyedandClaude Opus 5 8d7e3118b5 Migrate remaining encrypted-column lookups to the blind index
Completes the set of queries that matched a freshly encrypted value against a
stored one, which only works while encryption is deterministic. Each keeps its
original comparison and adds an index comparison in the same WHERE, so nothing
changes today.

- passenger sign-in by email, service-staff sign-in, Firebase token lookup
- driver lookup by phone and by national number
- admin ride lookup and ride monitor (both tables)
- nabeh: driver status, user resolution, ride history, complaint submission

transit_org_admins lives in the transit database and has no index column, so
login there falls back to decrypting the small set of active admins and
comparing normalised numbers.

Schema: adds users.email_bidx/phone_bidx and driver.national_bidx with their
indexes.

Verified that every :*_bidx placeholder introduced is actually bound — an
unbound one is a fatal error at request time, not a silent miss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 16:36:32 +03:00
Hamza-AyedandClaude Opus 5 39b5a7fc7f Keep OTP phone numbers recoverable for customer-service follow-up
Storing the verification phone as a keyed HMAC made OTP lookups independent
of the encryption mode, but the hash is one-way — and customer service reads
those same rows to chase people who requested a code and never finished
registering. That workflow would have lost the number entirely.

The verification tables now carry both forms: phone_number holds the lookup
key, and a new phone_enc column holds the encrypted number, which is
decryptable when a human needs to call.

The two follow-up queries also compared the verification row against the
driver/passengers tables and the notes tables by matching ciphertext, which
only ever worked because encryption was deterministic. Under GCM every number
would have looked unregistered and every note would have disappeared. Both now
read the number from phone_enc and match on normalised plaintext, so they are
correct under either mode.

Rows written before phone_enc existed are skipped rather than shown without a
number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 16:27:29 +03:00
Hamza-AyedandClaude Opus 5 a1c19b052d Make OTP verification independent of the encryption mode
The verification tables (token_verification*, phone_verification*) use the
phone number as a lookup key: written when the code is sent, read when it is
checked. Storing it encrypted worked only because encryptData() is
deterministic — under AES-GCM the two sides would produce different
ciphertexts and no code would ever verify, locking every user out of
registration and OTP sign-in.

otpPhoneKey() stores a keyed HMAC of the normalised number instead. No schema
change is needed since the column is textual, local and international formats
now resolve to the same key, and the value cannot be reversed without the
pepper. It falls back to the previous behaviour when no pepper is configured.

Applied to both sides of every affected flow — request/verify, and the driver
and passenger send/verify pairs — including the OTP value itself where it is
compared by equality rather than decrypted. auth/otp/verify.php already
decrypts the token before comparing, so it needed no change there.

Also adds ENCRYPTION_MODE to EncryptionHelper: encryptData() writes GCM when
set to 'gcm', CBC otherwise. Verified in both directions — rows written under
CBC stay readable after switching, and rows written under GCM stay readable
after rolling back — so the switch is reversible by an environment variable.

The admin console's own OTP is unaffected: it keys the table by the stored
ciphertext read from adminUser, identical on both sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 16:18:16 +03:00
Hamza-AyedandClaude Opus 5 c9b4d14da6 Route account lookups through the blind index and keep it fresh on write
These are the paths that must stop depending on deterministic encryption
before storage can move to AES-GCM. Each keeps its original ciphertext
comparison in the same statement, so behaviour is unchanged today and no
account becomes unreachable during the transition.

Lookups:
- auth/login.php — passenger sign-in matched the raw value against the
  encrypted column, which only works because encryptData() is CBC with a
  fixed IV.
- auth/passenger/register.php and auth/driver/register.php — duplicate
  detection. Without the index these would stop detecting existing accounts
  under GCM and allow the same phone to register twice.

Writes now populate the index in the same statement as the value:
- both registration paths write phone/email/name indexes with the row;
  driver indexes are computed before the encryption pass, since the raw
  values are unavailable afterwards.
- passenger profile update and admin driver update refresh the index when
  the underlying field changes. For the composite name index the untouched
  half is read back from the row.

Adds --audit to the backfill script: recomputes every index from its
encrypted value and reports missing or stale entries. Drift here is silent
by nature — it surfaces only when a real search fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 16:04:40 +03:00
Hamza-AyedandClaude Opus 5 15f55ff3e4 Add --verify to the backfill script
Confirms a value is findable through the blind index after backfilling,
without opening the console or the database. Prints matched row ids only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:59:50 +03:00
Hamza-AyedandClaude Opus 5 761b957c96 Silence REQUEST_METHOD warning when bootstrap runs from the CLI
The migration and backfill scripts load bootstrap.php outside a request, where
$_SERVER['REQUEST_METHOD'] does not exist, so every CLI run printed a warning
before its output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:55:18 +03:00
Hamza-AyedandClaude Opus 5 57e22477fb Add an idempotent CLI migration runner
phpMyAdmin is unreachable on this deployment, so schema changes need a path
that does not depend on it. migrate.php reuses core/Database, meaning no
credentials are passed on the command line, and checks information_schema
before each ALTER so re-running is safe and never fails on a duplicate
column. Supports --status and --dry-run.

Covers the blind-index columns and the missing adminUser status/approved_by/
approved_at columns. Every change is additive and nullable, so applying it to
a running database changes no behaviour on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:35:34 +03:00
Hamza-AyedandClaude Opus 5 6802026dbd Add blind-index search layer; fix captain detail 200-with-empty-body
Searching encrypted columns currently works only because encryptData() is
AES-CBC with a fixed IV, i.e. deterministic. That determinism is what leaks
equality and shared prefixes, and it is why moving storage to AES-GCM would
break every lookup. This separates the two concerns.

- core/Security/BlindIndex.php: HMAC-SHA256 over a normalised value, keyed by
  a secret pepper. Phone numbers have a small keyspace, so a bare SHA-256
  would be reversible by enumeration; the pepper lives in the environment, not
  the database. The scope string includes table and field so the same number
  does not produce a matching index across tables.
  Normalisation unifies local/international phone forms, lowercases emails and
  folds Arabic alef/ya/ta-marbuta and diacritics for names.
- migrations/: nullable *_bidx columns plus indexes, and the missing
  adminUser.status/approved_by/approved_at columns that admin approvals need.
- scripts/backfill_blind_index.php: restartable, batched, --dry-run capable,
  touches only index columns.
- Admin lookups by phone/email now match the index, keeping the old ciphertext
  comparison in the same query so search keeps working until the backfill runs.
  bootstrap exposes $blindIndex as null when no pepper is configured.

Also: AdminCaptain/getCaptainDetailsById.php selected driver.education, a
column absent from this schema. The PDOException was uncaught, so the client
received an empty body with HTTP 200 — the "non-JSON response" seen when
opening a captain. It now omits the column, catches the error, reports it as
JSON, and requires an admin role.

Console: opening any sidebar section refetches its data instead of showing
what was loaded when the console started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:16:09 +03:00
Hamza-AyedandClaude Opus 5 81ee2acefd Add removal/blacklist tools, Arabic UI, and a persistent sign-in error
Authorisation: driver/deleteCaptain.php permanently deletes a captain and had
no role check at all — any valid token, including a driver's or passenger's,
could delete captains. It now requires super_admin. The two unblacklist
endpoints had no role check either and now require an admin role.

Console:
- Blacklist & removal screen (super admin only): current blocks, permanent
  removal, and lifting a block. Deletion additionally requires the phone
  number to be retyped, because the row is deleted outright.
- Arabic interface with full RTL layout and a language toggle. Sidebar,
  content offset, input affordances, toasts and directional icons all mirror;
  numbers, identifiers and the diagnostics output stay LTR.
- Sign-in failures now render in the form and stay there. They were only
  shown as a toast, which disappears before it can be read — a locked
  account or a rejected device looked like the page simply returning to
  the login screen with no explanation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 14:53:26 +03:00
349 changed files with 293578 additions and 6392 deletions
+2 -2
View File
@@ -70,8 +70,8 @@ DerivedData/
xcuserdata/
# --- Composer / PHP ---
/composer.lock
**/composer.lock
# composer.lock مُتتبَّع عمداً: بدونه ينهار payment_server/v2 بعد أي نشر نظيف
# (vendor يبقى مستثنى أعلاه — يُبنى بـ composer install من الـ lock)
# --- Logs ---
*.log
+3
View File
@@ -17,6 +17,9 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "BOT_SECRET_KEY", "\"SIRO_BOT_SUPER_SECRET_123\"")
// مضيف الباك إند في مكان واحد: تبديله عند نسخ البوت لعلامة أخرى
// (مثل انطلق على api.intaleqapp.com) يصير سطراً واحداً لا بحثاً في الكود.
buildConfigField("String", "BACKEND_HOST", "\"https://jordan-siro.intaleqapp.com\"")
}
buildTypes {
@@ -25,9 +25,11 @@ class WorkerClient(private val context: Context) {
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: "UNKNOWN_DEVICE"
}
// Change this to your actual server domain
private val BASE_URL = "https://jordan-siro.intaleqapp.com/backend/bot/standalone_worker.php"
// For local testing use: "http://10.0.2.2:8000/standalone_worker.php"
// المضيف من BuildConfig.BACKEND_HOST (app/build.gradle.kts) — لا يُشفَّر هنا،
// حتى يكون تبديله عند نسخ البوت لعلامة أخرى سطراً واحداً في gradle.
// للتجريب المحلي: اضبط BACKEND_HOST على "http://10.0.2.2:8000" مع تقديم
// نفس المسار /backend/bot/ من جذر السيرفر المحلي.
private val BASE_URL = "${BuildConfig.BACKEND_HOST}/backend/bot/standalone_worker.php"
private fun generateSignature(deviceId: String, ts: Long): String {
val message = "$deviceId$ts"
@@ -5,6 +5,15 @@ $driver_id = filterRequest("driver_id");
$driverEmail = $encryptionHelper->encryptData(filterRequest("driverEmail"));
$driverPhone = $encryptionHelper->encryptData(filterRequest("driverPhone"));
/**
* الفهرس الأعمى: يسمح بالبحث بعد نقل التخزين إلى AES-GCM العشوائي.
* تُبقى المقارنة القديمة في نفس الاستعلام كاحتياط حتى تنتهي تعبئة الفهارس.
*/
global $blindIndex;
$emailBidx = $blindIndex ? $blindIndex->index('driver.email', filterRequest("driverEmail")) : null;
$phoneBidx = $blindIndex ? $blindIndex->index('driver.phone', filterRequest("driverPhone")) : null;
$sql = "SELECT
`driver`.`id`,
`driver`.`phone`,
@@ -53,6 +62,8 @@ $sql = "SELECT
) AS passengerToken
FROM `driver`
WHERE `driver`.`email` = :email OR `driver`.`phone` = :phone OR `driver`.`id` = :id
OR (:email_bidx IS NOT NULL AND `driver`.`email_bidx` = :email_bidx)
OR (:phone_bidx IS NOT NULL AND `driver`.`phone_bidx` = :phone_bidx)
ORDER BY passengerAverageRating DESC
LIMIT 10
";
@@ -61,6 +72,8 @@ $stmt = $con->prepare($sql);
$stmt->bindParam(":email", $driverEmail);
$stmt->bindParam(":phone", $driverPhone);
$stmt->bindParam(":id", $driver_id);
$stmt->bindParam(":email_bidx", $emailBidx);
$stmt->bindParam(":phone_bidx", $phoneBidx);
$stmt->execute();
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
@@ -1,9 +1,18 @@
<?php
require_once __DIR__ . '/../../connect.php';
// تشفير driver_id قبل استخدامه في SQL
if ($role !== 'admin' && $role !== 'super_admin') {
http_response_code(403);
echo json_encode(['error' => 'Unauthorized: Admin access required']);
exit;
}
$driver_id = filterRequest("driver_id");
if (empty($driver_id)) {
jsonError("driver_id is required", 400);
}
$sql = "SELECT
`driver`.`id`,
`driver`.`phone`,
@@ -14,7 +23,6 @@ $sql = "SELECT
`driver`.`site`,
`driver`.`first_name`,
`driver`.`last_name`,
`driver`.`education`,
`driver`.`employmentType`,
`driver`.`maritalStatus`,
`driver`.`created_at`,
@@ -59,14 +67,23 @@ WHERE `driver`.`id` = :driver_id
ORDER BY passengerAverageRating DESC
LIMIT 10";
$stmt = $con->prepare($sql);
$stmt->bindParam(':driver_id', $driver_id);
$stmt->execute();
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
try {
$stmt = $con->prepare($sql);
$stmt->bindParam(':driver_id', $driver_id);
$stmt->execute();
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
// بلا هذا الالتقاط كان الاستثناء يُنهي السكربت فيصل للعميل جسم فارغ
// بحالة HTTP 200، فيظهر كـ "رد غير JSON".
error_log("[getCaptainDetailsById] " . $e->getMessage());
jsonError("Could not read the captain record: " . $e->getMessage(), 500);
}
// فك تشفير الحقول الحساسة بعد الجلب
foreach ($result as &$row) {
foreach (['phone','email','gender','birthdate','site','first_name','last_name','employmentType','maritalStatus'] as $f) {
if (!array_key_exists($f, $row)) $row[$f] = null;
}
$row['phone'] = $encryptionHelper->decryptData($row['phone']);
$row['email'] = $encryptionHelper->decryptData($row['email']);
$row['gender'] = $encryptionHelper->decryptData($row['gender']);
@@ -74,7 +91,6 @@ foreach ($result as &$row) {
$row['site'] = $encryptionHelper->decryptData($row['site']);
$row['first_name'] = $encryptionHelper->decryptData($row['first_name']);
$row['last_name'] = $encryptionHelper->decryptData($row['last_name']);
$row['education'] = $encryptionHelper->decryptData($row['education']);
$row['employmentType'] = $encryptionHelper->decryptData($row['employmentType']);
$row['maritalStatus'] = $encryptionHelper->decryptData($row['maritalStatus']);
}
+38 -17
View File
@@ -39,6 +39,7 @@ if ($redis && !empty($phone)) {
// البحث عن المشرف باستخدام بصمة الجهاز (Fingerprint Hash)
$fpHash = hash('sha256', $fingerprint);
$isTrustedDevice = false;
// تسجيل محاولة تسجيل الدخول للتدقيق
$loginAuditData = [
@@ -57,11 +58,29 @@ try {
$stmt->execute([':fp' => $fpHash]);
$admin = $stmt->fetch(PDO::FETCH_ASSOC);
// إذا لم يتم العثور بالبصمة، نبحث بالـ ID المباشر أو بفك تشفير البيانات (AES-GCM Decryption in PHP)
if (!$admin && !empty($phone)) {
// 1. بحث مباشر بالـ ID المعياري Unencrypted
$stmtId = $con->prepare("SELECT * FROM adminUser WHERE id = :id LIMIT 1");
$stmtId->execute([':id' => $phone]);
if ($admin) {
$isTrustedDevice = true;
} else if (!empty($phone)) {
// 1. بحث بالـ ID أو الفهارس العمياء (الهاتف / البريد) لضمان السرعة والتوافق مع التشفير المتغير
global $blindIndex;
$phoneBidx = $blindIndex ? $blindIndex->index('adminUser.phone', $phone) : null;
$emailBidx = $blindIndex ? $blindIndex->index('adminUser.email', $phone) : null;
$sql = "SELECT * FROM adminUser WHERE id = :id";
$params = [':id' => $phone];
if ($phoneBidx) {
$sql .= " OR phone_bidx = :phone_bidx";
$params[':phone_bidx'] = $phoneBidx;
}
if ($emailBidx) {
$sql .= " OR email_bidx = :email_bidx";
$params[':email_bidx'] = $emailBidx;
}
$sql .= " LIMIT 1";
$stmtId = $con->prepare($sql);
$stmtId->execute($params);
$admin = $stmtId->fetch(PDO::FETCH_ASSOC);
// 2. إذا لم يتم العثور بالـ ID، نفحص الحقول المشفّرة (email / phone / name) عبر فك التشفير
@@ -79,16 +98,9 @@ try {
}
}
// عند إيجاد الحساب وتأكيد كلمة المرور، نقوم بتحديث وبث بصمة الجهاز للجلسة
if ($admin && password_verify($password, $admin['password'])) {
$encFpRaw = $encryptionHelper ? $encryptionHelper->encryptData($fingerprint) : $fingerprint;
$updateStmt = $con->prepare("UPDATE adminUser SET fingerprint = :fp_raw, fingerprint_hash = :fp WHERE id = :id");
$updateStmt->execute([
':fp_raw' => $encFpRaw,
':fp' => $fpHash,
':id' => $admin['id']
]);
$admin['fingerprint_hash'] = $fpHash;
// فحص ما إذا كانت بصمة الجهاز محفوظة ومطابقة للجهاز الحالي
if ($admin && !empty($admin['fingerprint_hash']) && hash_equals($admin['fingerprint_hash'], $fpHash)) {
$isTrustedDevice = true;
}
}
@@ -110,8 +122,17 @@ try {
// 2. التحقق من كلمة المرور
if (password_verify($password, $admin['password'])) {
// إذا كان تجديد توكن تلقائي من التطبيق/الجهاز الموثوق
if ($isRenewal) {
// إذا كان الجهاز موثوقاً (البصمة محفوظة ومطابقة) أو طلب تجديد توكن تلقائي
if ($isTrustedDevice || $isRenewal) {
$encFpRaw = $encryptionHelper ? $encryptionHelper->encryptData($fingerprint) : $fingerprint;
$updateStmt = $con->prepare("UPDATE adminUser SET fingerprint = :fp_raw, fingerprint_hash = :fp WHERE id = :id");
$updateStmt->execute([
':fp_raw' => $encFpRaw,
':fp' => $fpHash,
':id' => $admin['id']
]);
$admin['fingerprint_hash'] = $fpHash;
$jwtService = new JwtService($redis);
$role = $admin['role'] ?? 'admin';
+47 -9
View File
@@ -22,14 +22,55 @@ $rateLimiter->enforce(RateLimiter::identifier(), 'otp');
try {
$con = Database::get('main');
// 1. جلب بيانات المسؤول عبر البصمة (مصدر موثوق وغير مشفر)
// 1. جلب بيانات المسؤول عبر البصمة أو من الـ OTP المعلق للجهاز الجديد
$fpHash = hash('sha256', $fingerprint);
$stmt = $con->prepare("SELECT * FROM adminUser WHERE fingerprint_hash = :fp LIMIT 1");
$stmt->execute([':fp' => $fpHash]);
$admin = $stmt->fetch(PDO::FETCH_ASSOC);
$otpHash = hash('sha256', (string)$otp);
if (!$admin) {
jsonError("المسؤول غير موجود أو البصمة غير مطابقة.");
// إذا كانت البصمة جديدة وغير مسجلة بعد، نبحث عن الحساب المرتبط بـ OTP المعلق
$stmtOtp = $con->prepare("SELECT phone_number FROM token_verification_admin WHERE token = ? AND expiration_time >= NOW() LIMIT 1");
$stmtOtp->execute([$otpHash]);
$otpRow = $stmtOtp->fetch(PDO::FETCH_ASSOC);
if ($otpRow && !empty($otpRow['phone_number'])) {
// $targetPhone هو الرقم المشفر من token_verification_admin (نفس القيمة المخزنة في adminUser.phone)
$targetPhone = $otpRow['phone_number'];
global $encryptionHelper;
// البحث المباشر: phone المشفر مطابق لنفس النص المشفر في adminUser.phone
$stmtAdmin = $con->prepare("SELECT * FROM adminUser WHERE phone = :p1 OR id = :p2 LIMIT 1");
$stmtAdmin->execute([':p1' => $targetPhone, ':p2' => $targetPhone]);
$admin = $stmtAdmin->fetch(PDO::FETCH_ASSOC);
// مسار احتياطي: فك التشفير لمقارنة القيم (ضروري لـ AES-GCM حيث التشفير غير حتمي)
if (!$admin) {
$decTarget = ($encryptionHelper && !empty($targetPhone)) ? $encryptionHelper->decryptData($targetPhone) : null;
$stmtAll = $con->query("SELECT * FROM adminUser");
while ($row = $stmtAll->fetch(PDO::FETCH_ASSOC)) {
// مقارنة مباشرة للنصوص المشفرة (نفس ciphertext)
if ($targetPhone === $row['phone']) {
$admin = $row;
break;
}
// مقارنة عبر فك التشفير (AES-GCM: ciphertexts مختلفة لنفس النص)
if ($decTarget) {
$decPhone = ($encryptionHelper && !empty($row['phone'])) ? $encryptionHelper->decryptData($row['phone']) : $row['phone'];
if ($decTarget === $decPhone) {
$admin = $row;
break;
}
}
}
}
}
}
if (!$admin) {
jsonError("المسؤول غير موجود أو رمز التحقق غير صالح.");
exit;
}
@@ -39,10 +80,7 @@ try {
// فك تشفيره لو احتجنا إرساله أو عرضه، لكن هنا نحن نحتاج المشفر للبحث
// $phone = $encryptionHelper->decryptData($encryptedPhone);
// هاش الرمز (OTP) القادم من التطبيق للمقارنة
$otpHash = hash('sha256', (string)$otp);
// 3. التحقق من الـ OTP
// 3. التحقق من الـ OTP (الهاش محسوب مسبقاً في المتغير $otpHash)
$stmt = $con->prepare("SELECT * FROM token_verification_admin
WHERE phone_number = ? AND token = ?
AND expiration_time >= NOW()");
@@ -93,7 +131,7 @@ try {
"expires_in" => 3600
]);
} catch (Exception $e) {
error_log("[Admin Verify OTP Error] " . $e->getMessage());
jsonError("An internal error occurred. Please try again later.");
} catch (Throwable $e) {
error_log("[Admin Verify OTP Error] " . $e->getMessage() . "\nTrace: " . $e->getTraceAsString());
jsonError("Server Error: " . $e->getMessage() . " on line " . $e->getLine());
}
+26 -10
View File
@@ -9,18 +9,34 @@ if (empty($phone)) {
}
try {
// تشفير الرقم المدخل للبحث
$encPhone = $encryptionHelper->encryptData($phone);
/**
* البحث عبر الفهرس الأعمى أولاً (phone_bidx): مطابقة تامة عبر فهرس مُهيأ
* ولا تعتمد على كون التشفير حتمياً، فتظل تعمل بعد النقل إلى AES-GCM.
*
* يُبقى المسار القديم (مقارنة النص المشفّر) كاحتياط حتى ينتهي تشغيل
* scripts/backfill_blind_index.php، وإلا لتوقّف البحث بين الترحيل والتعبئة.
*/
global $blindIndex;
$driver = null;
// احضار كل الأعمدة باستثناء كلمة المرور
$sql = "SELECT *
FROM driver
WHERE phone = :phone
LIMIT 1";
$stmt = $con->prepare($sql);
$stmt->execute([':phone' => $encPhone]);
if ($blindIndex) {
$bidx = $blindIndex->index('driver.phone', $phone);
if ($bidx) {
$stmt = $con->prepare("SELECT * FROM driver WHERE phone_bidx = :bidx LIMIT 1");
$stmt->execute([':bidx' => $bidx]);
$driver = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
}
$driver = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$driver) {
$encPhone = $encryptionHelper->encryptData($phone);
$stmt = $con->prepare("SELECT * FROM driver WHERE phone = :phone LIMIT 1");
$stmt->execute([':phone' => $encPhone]);
}
if (!$driver) {
$driver = $stmt->fetch(PDO::FETCH_ASSOC);
}
if ($driver) {
// ✅ الحقول المشفرة اللي لازم تنفك:
@@ -24,6 +24,13 @@ if ($phone !== null && $phone !== '') {
$encphone = $encryptionHelper->encryptData($phone);
$updateFields[] = "`phone` = :phone";
$params[':phone'] = $encphone;
// الفهرس يُحدَّث مع الرقم نفسه حتى لا يشير إلى القيمة القديمة
global $blindIndex;
if ($blindIndex) {
$updateFields[] = "`phone_bidx` = :phone_bidx";
$params[':phone_bidx'] = $blindIndex->index('driver.phone', $phone);
}
}
if ($status !== null && $status !== '') {
+13
View File
@@ -5,6 +5,15 @@ $passengerEmail = $encryptionHelper->encryptData(filterRequest("passengerEmail")
$passengerId = filterRequest("passengerId");
$passengerphone = $encryptionHelper->encryptData(filterRequest("passengerphone"));
/**
* الفهرس الأعمى: يسمح بالبحث بعد نقل التخزين إلى AES-GCM العشوائي.
* تُبقى المقارنة القديمة في نفس الاستعلام كاحتياط حتى تنتهي تعبئة الفهارس.
*/
global $blindIndex;
$emailBidx = $blindIndex ? $blindIndex->index('passengers.email', filterRequest("passengerEmail")) : null;
$phoneBidx = $blindIndex ? $blindIndex->index('passengers.phone', filterRequest("passengerphone")) : null;
$sql = "SELECT
`passengers`.`id`,
`passengers`.`phone`,
@@ -59,12 +68,16 @@ FROM
`passengers`
WHERE
passengers.email = :email OR passengers.phone = :phone OR passengers.id = :id
OR (:email_bidx IS NOT NULL AND passengers.email_bidx = :email_bidx)
OR (:phone_bidx IS NOT NULL AND passengers.phone_bidx = :phone_bidx)
";
$stmt = $con->prepare($sql);
$stmt->bindParam(":email", $passengerEmail);
$stmt->bindParam(":phone", $passengerphone);
$stmt->bindParam(":id", $passengerId);
$stmt->bindParam(":email_bidx", $emailBidx);
$stmt->bindParam(":phone_bidx", $phoneBidx);
$stmt->execute();
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
+4 -7
View File
@@ -6,14 +6,11 @@
require_once __DIR__ . '/../core/bootstrap.php';
header('Content-Type: application/json');
header("Access-Control-Allow-Origin: https://siromove.com");
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization");
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
// ── Rate Limiting ───────────────────────────────────────────
$limiter = new RateLimiter($redis);
+60
View File
@@ -0,0 +1,60 @@
<?php
require_once __DIR__ . '/../core/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
// Simple mocking / getting of real data if possible
$cpuLoad = sys_getloadavg();
$load1m = $cpuLoad ? $cpuLoad[0] : 0.5;
$cores = 4; // Mock or try to read from /proc/cpuinfo
$cpuPercent = min(100, ($load1m / $cores) * 100);
$freeDisk = disk_free_space("/");
$totalDisk = disk_total_space("/");
$usedDisk = $totalDisk - $freeDisk;
$diskPercent = ($usedDisk / $totalDisk) * 100;
// Dummy Memory (PHP can't natively read total system memory cross-platform easily without exec)
$memTotalGb = 16.0;
$memUsedGb = 8.4;
$memPercent = ($memUsedGb / $memTotalGb) * 100;
$response = [
'cpu' => [
'percent' => round($cpuPercent, 2),
'cores' => $cores,
'load_1m' => round($load1m, 2)
],
'memory' => [
'percent' => round($memPercent, 2),
'used_gb' => $memUsedGb,
'total_gb' => $memTotalGb
],
'disk' => [
'percent' => round($diskPercent, 2),
'used_gb' => round($usedDisk / 1073741824, 2),
'total_gb' => round($totalDisk / 1073741824, 2)
],
'services' => [
'Nginx' => 'running',
'MySQL' => 'running',
'Redis' => 'running',
'PHP-FPM' => 'running'
],
'top_processes' => [
['name' => 'mysql', 'usage' => '12.4%'],
['name' => 'nginx', 'usage' => '3.1%'],
['name' => 'php-fpm', 'usage' => '2.5%'],
['name' => 'redis-server', 'usage' => '1.2%']
],
'network' => [
'received_mb' => rand(100, 500) + (rand(0, 99) / 100),
'sent_mb' => rand(50, 300) + (rand(0, 99) / 100)
],
'uptime' => [
'formatted' => '12 days, 4 hours, 32 mins'
],
'timestamp' => date('Y-m-d H:i:s')
];
echo json_encode($response);
+6 -3
View File
@@ -76,7 +76,9 @@ if (function_exists('logAudit')) {
}
// الاستدعاء الداخلي لخدمة FCM
$fcmUrl = getenv('FCM_INTERNAL_URL') ?: 'http://127.0.0.1/backend/ride/firebase/send_fcm.php';
// من داخل حاوية php لا يوجد خادم ويب على 127.0.0.1 — الويب في حاوية nginx
// منفصلة، وتُعرف داخل شبكة Compose باسم الخدمة. هذا كان سبب فشل كل إشعار.
$fcmUrl = getenv('FCM_INTERNAL_URL') ?: 'http://nginx/backend/ride/firebase/send_fcm.php';
$payload = json_encode([
'target' => $topic,
'title' => $title,
@@ -106,8 +108,9 @@ $curlErr = curl_error($ch);
curl_close($ch);
if ($response === false || $httpCode >= 400) {
error_log("[Broadcast] FCM call failed (HTTP $httpCode): " . ($curlErr ?: $response));
jsonError("Notification service rejected the request (HTTP $httpCode).", 502);
$reason = $curlErr ?: (is_string($response) ? substr($response, 0, 200) : 'no response');
error_log("[Broadcast] FCM call failed (HTTP $httpCode) via $fcmUrl: $reason");
jsonError("Notification service unreachable at $fcmUrl — $reason", 502);
}
$decoded = json_decode((string) $response, true);
@@ -29,20 +29,20 @@ try {
$selP = $con->prepare("
SELECT id, first_name, last_name, phone
FROM passengers
WHERE phone = :enc_raw
WHERE phone = :enc_raw OR (:bidx IS NOT NULL AND phone_bidx = :bidx)
LIMIT 1
");
$selP->execute(['enc_raw' => $enc_raw]);
$selP->execute(['enc_raw' => $enc_raw, 'bidx' => $pBidx]);
$passenger = $selP->fetch(PDO::FETCH_ASSOC);
// 2) ابحث عن السائق بالهاتف المشفّر
$selD = $con->prepare("
SELECT id AS driverID, first_name, last_name, phone
FROM driver
WHERE phone = :enc_raw
WHERE phone = :enc_raw OR (:bidx IS NOT NULL AND phone_bidx = :bidx)
LIMIT 1
");
$selD->execute(['enc_raw' => $enc_raw]);
$selD->execute(['enc_raw' => $enc_raw, 'bidx' => $dBidx]);
$driver = $selD->fetch(PDO::FETCH_ASSOC);
$userId = null;
+1 -1
View File
@@ -6,7 +6,7 @@
require_once __DIR__ . '/../../connect.php'; // تأكد أن هذا الملف يحتوي على $con_tracking
header("Access-Control-Allow-Origin: https://siromove.com");
header("Content-Type: application/json; charset=UTF-8");
try {
+1 -1
View File
@@ -1,7 +1,7 @@
<?php
require_once __DIR__ . '/../../connect.php';
header("Access-Control-Allow-Origin: https://siromove.com");
header('Content-Type: application/json; charset=utf-8');
try {
+11 -4
View File
@@ -23,16 +23,21 @@ error_log("[MONITOR_RIDE] 1.5 Normalized Phone: " . $phone);
//------------------------------------------------------------------------
$encPhone = $encryptionHelper->encryptData($phone);
// فهرس البحث لكل جدول على حدة (النطاقات معزولة عمداً)
global $blindIndex;
$dBidx = $blindIndex ? $blindIndex->index('driver.phone', $phone) : null;
$pBidx = $blindIndex ? $blindIndex->index('passengers.phone', $phone) : null;
error_log("[MONITOR_RIDE] 2. Encrypted Phone: " . $encPhone);
// Check Driver Table
$driverQuery = $con->prepare("SELECT id AS driverID FROM driver WHERE phone = :phone LIMIT 1");
$driverQuery->execute([':phone' => $encPhone]);
$driverQuery = $con->prepare("SELECT id AS driverID FROM driver WHERE phone = :phone OR (:bidx IS NOT NULL AND phone_bidx = :bidx) LIMIT 1");
$driverQuery->execute([':phone' => $encPhone, ':bidx' => $dBidx]);
$driver = $driverQuery->fetch(PDO::FETCH_ASSOC);
// Check Passenger Table
$customerQuery = $con->prepare("SELECT id AS customerID FROM passengers WHERE phone = :phone LIMIT 1");
$customerQuery->execute([':phone' => $encPhone]);
$customerQuery = $con->prepare("SELECT id AS customerID FROM passengers WHERE phone = :phone OR (:bidx IS NOT NULL AND phone_bidx = :bidx) LIMIT 1");
$customerQuery->execute([':phone' => $encPhone, ':bidx' => $pBidx]);
$customer = $customerQuery->fetch(PDO::FETCH_ASSOC);
// حدد نوع المستخدم
@@ -147,5 +152,7 @@ $response = [
"driver_location" => $location ?: "No live location"
];
error_log("[MONITOR_RIDE] 7. Sending Success Response.");
jsonSuccess($response);
error_log("[MONITOR_RIDE] 7. Sending Success Response.");
jsonSuccess($response);
@@ -17,7 +17,7 @@ try {
SUM(r.price) as total_revenue
FROM driver d
JOIN ride r ON d.id = r.driver_id
WHERE r.status = 'Finished'
WHERE LOWER(r.status) IN ('finished','completed')
GROUP BY d.id, d.first_name, d.last_name, d.phone
ORDER BY completed_rides DESC
LIMIT 10
+2 -2
View File
@@ -17,7 +17,7 @@ try {
SUM(price - price_for_driver) as company_profit,
COUNT(*) as total_rides
FROM ride
WHERE status = 'Finished'
WHERE LOWER(status) IN ('finished','completed')
AND created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY DATE(created_at)
ORDER BY date ASC
@@ -32,7 +32,7 @@ try {
SUM(price - price_for_driver) as total_profit_all,
AVG(price) as avg_ride_price
FROM ride
WHERE status = 'Finished'
WHERE LOWER(status) IN ('finished','completed')
AND created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
");
$stmt->execute();
+1 -1
View File
@@ -17,7 +17,7 @@ try {
SUM(r.price_for_driver) as total_earned,
COUNT(r.id) as total_rides
FROM driver d
LEFT JOIN ride r ON d.id = r.driver_id AND r.status = 'Finished'
LEFT JOIN ride r ON d.id = r.driver_id AND LOWER(r.status) IN ('finished','completed')
GROUP BY d.id
HAVING total_earned > 0
ORDER BY total_earned DESC
+1 -1
View File
@@ -18,7 +18,7 @@ try {
0 as cash_payments,
0 as digital_payments
FROM ride
WHERE status = 'Finished'
WHERE LOWER(status) IN ('finished','completed')
");
$stmt->execute();
$stats = $stmt->fetch(PDO::FETCH_ASSOC);
@@ -40,7 +40,7 @@ try {
$stmt = $con->prepare("
SELECT
COUNT(*) as total_rides,
SUM(CASE WHEN status = 'Finished' THEN 1 ELSE 0 END) as completed_rides,
SUM(CASE WHEN LOWER(status) IN ('finished','completed') THEN 1 ELSE 0 END) as completed_rides,
SUM(CASE WHEN status = 'cancel' AND cancel_by = 'driver' THEN 1 ELSE 0 END) as driver_cancellations,
SUM(CASE WHEN status = 'cancel' AND cancel_by = 'passenger' THEN 1 ELSE 0 END) as passenger_cancellations
FROM ride
+2 -2
View File
@@ -26,12 +26,12 @@ try {
$online_drivers = $stmt->fetchColumn();
// 3. إيرادات اليوم
$stmt = $con->prepare("SELECT IFNULL(SUM(price_for_passenger), 0) FROM ride WHERE status = 'Finished' AND DATE(created_at) = CURDATE()");
$stmt = $con->prepare("SELECT IFNULL(SUM(price_for_passenger), 0) FROM ride WHERE LOWER(status) IN ('finished','completed') AND DATE(created_at) = CURDATE()");
$stmt->execute();
$revenue_today = $stmt->fetchColumn();
// إيرادات الأمس (للمقارنة)
$stmt = $con->prepare("SELECT IFNULL(SUM(price_for_passenger), 0) FROM ride WHERE status = 'Finished' AND DATE(created_at) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)");
$stmt = $con->prepare("SELECT IFNULL(SUM(price_for_passenger), 0) FROM ride WHERE LOWER(status) IN ('finished','completed') AND DATE(created_at) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)");
$stmt->execute();
$revenue_yesterday = $stmt->fetchColumn();
@@ -4,7 +4,7 @@ require_once __DIR__ . '/../../connect.php';
// استقبال وتشفير رقم الهاتف
$phoneNumber = filterRequest("phone_number");
$phoneNumber = $encryptionHelper->encryptData($phoneNumber);
$phoneNumber = otpPhoneKey($phoneNumber);
// تجهيز الاستعلام باستخدام bindParam للحماية
$sql = "SELECT * FROM `phone_verification` WHERE `phone_number` = :phone_number";
+1 -1
View File
@@ -4,7 +4,7 @@ require_once __DIR__ . '/../../../connect.php';
$phoneNumber = filterRequest("phone_number");
// تشفير الرقم قبل البحث
$phoneNumber_encrypted = $encryptionHelper->encryptData($phoneNumber);
$phoneNumber_encrypted = otpPhoneKey($phoneNumber);
try {
// الاستعلام عن السائق حسب رقم الهاتف وحالة التحقق
+67 -10
View File
@@ -2,13 +2,45 @@
require_once __DIR__ . '/../../connect.php';
global $blindIndex;
$email = filterRequest('email');
$phone = filterRequest('phone');
$password = filterRequest('password');
// تشفير الحقول المطلوبة قبل الاستعلام
$email = $encryptionHelper->encryptData($email);
$phone = $encryptionHelper->encryptData($phone);
if (empty($phone) && empty($email)) {
jsonError("Phone or email is required.");
exit;
}
$conditions = [];
$params = [];
if (!empty($phone)) {
$phoneEnc = $encryptionHelper->encryptData($phone);
$conditions[] = "driver.phone = :phone";
$params[':phone'] = $phoneEnc;
$phoneBidx = $blindIndex ? $blindIndex->index('driver.phone', $phone) : null;
if ($phoneBidx) {
$conditions[] = "driver.phone_bidx = :phone_bidx";
$params[':phone_bidx'] = $phoneBidx;
}
}
if (!empty($email)) {
$emailEnc = $encryptionHelper->encryptData($email);
$conditions[] = "driver.email = :email";
$params[':email'] = $emailEnc;
$emailBidx = $blindIndex ? $blindIndex->index('driver.email', $email) : null;
if ($emailBidx) {
$conditions[] = "driver.email_bidx = :email_bidx";
$params[':email_bidx'] = $emailBidx;
}
}
$whereClause = implode(' OR ', $conditions);
$sql = "SELECT
driver.id,
@@ -25,19 +57,44 @@ $sql = "SELECT
driver.maritalStatus,
driver.created_at,
driver.updated_at,
email_verifications.verified
driver.email AS _email_enc
FROM
driver
LEFT JOIN email_verifications ON email_verifications.email = driver.email
WHERE
driver.phone = :phone AND driver.email = :email";
$whereClause";
/**
* حالة توثيق البريد.
*
* كان الاستعلام يربط email_verifications.email بعمود البريد في الحساب، لكن
* الأول يُخزَّن نصاً صريحاً والثاني مشفّراً — فالربط لم يكن يطابق شيئاً أصلاً
* وكانت verified تعود NULL دائماً. نجلبها هنا بالبريد الأصلي.
*/
function fetchEmailVerified(PDO $con, ?string $plainEmail): ?int
{
if (!$plainEmail) return null;
try {
$st = $con->prepare("SELECT verified FROM email_verifications WHERE email = ? LIMIT 1");
$st->execute([$plainEmail]);
$v = $st->fetchColumn();
return $v === false ? null : (int) $v;
} catch (PDOException $e) {
error_log('[email_verifications] ' . $e->getMessage());
return null;
}
}
$stmt = $con->prepare($sql);
$stmt->bindParam(':email', $email);
$stmt->bindParam(':phone', $phone);
$stmt->execute();
$stmt->execute($params);
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
$count = $stmt->rowCount();
$count = count($data);
if ($count > 0) {
$plainEmail = $encryptionHelper->decryptData($data[0]['_email_enc'] ?? null) ?: null;
$data[0]['verified'] = fetchEmailVerified($con, $plainEmail);
unset($data[0]['_email_enc']);
}
if ($count > 0) {
$stored_password = $data[0]['password'];
+1 -1
View File
@@ -23,7 +23,7 @@ try {
CarRegistration.make, CarRegistration.model, CarRegistration.year,
df.is_claimed, inv.isInstall, inv.isGiftToken
FROM driver
LEFT JOIN phone_verification ON phone_verification.phone_number = driver.phone
LEFT JOIN phone_verification ON phone_verification.phone_number = driver.phone_key
LEFT JOIN driver_gifts df ON df.driver_id = driver.id
LEFT JOIN CarRegistration ON CarRegistration.driverID = driver.id
LEFT JOIN invites inv ON inv.driverId = driver.id
@@ -11,18 +11,16 @@ $password = filterRequest('password');
$audience = filterRequest('aud') ?? 'siro-driver-android'; // الافتراضي
$fingerprint = filterRequest('fingerPrint') ?? filterRequest('fingerprint');
// 1. تطبيق حد معدل الطلبات (Rate Limiting) للفاحصين: 3 محاولات بالدقيقة لكل IP
// 1. حد معدل الطلبات مطبّق على الجميع (الحد مرفوع إلى 30/دقيقة في RateLimiter)
$rateLimiter = new RateLimiter($redis);
$rateLimiter->enforce(RateLimiter::identifier(), 'tester_login');
if (!$email || !$password) {
echo json_encode(["status" => "failure", "message" => "Email and password are required"]);
exit();
}
// 2. التحقق من أن الحساب مخصص للفحص فقط (isTest check)
$allowedTesterEmailsEnv = getenv('ALLOWED_TESTER_EMAILS') ?: '';
$allowedEmails = array_filter(array_map('trim', explode(',', $allowedTesterEmailsEnv)));
// 2. قائمة بيضاء صريحة لحسابات الفحص — مطابقة تامة فقط، لا مطابقة جزئية ولا مطابقة نطاق
$allowedTesterEmailsEnv = getenv('ALLOWED_TESTER_EMAILS') ?: ($_ENV['ALLOWED_TESTER_EMAILS'] ?? '');
$allowedEmails = array_filter(array_map(
fn($e) => strtolower(trim($e)),
explode(',', $allowedTesterEmailsEnv)
));
if (empty($allowedEmails)) {
$allowedEmails = [
'driver_tester@siromove.com',
@@ -30,21 +28,21 @@ if (empty($allowedEmails)) {
];
}
$cleanEmail = strtolower(trim($email));
$isTester = in_array($cleanEmail, $allowedEmails) ||
substr($cleanEmail, -13) === '@siromove.com' ||
str_contains($cleanEmail, 'tester') ||
str_contains($cleanEmail, 'reviewer');
$cleanEmail = strtolower(trim((string) $email));
$isTester = in_array($cleanEmail, $allowedEmails, true);
// تشفير الإيميل لاستخدامه في الاستعلام
$encryptedEmail = $encryptionHelper->encryptData($email);
if (!$email || !$password) {
echo json_encode(["status" => "failure", "message" => "Email and password are required"]);
exit();
}
try {
$con = Database::get('main');
// Auto-seed/create tester driver logic removed for security
// SQL لاسترجاع المستخدم بناءً على البريد الإلكتروني المشفر
$encryptedEmail = $encryptionHelper->encryptData($email);
global $blindIndex;
$emailBidx = $blindIndex ? $blindIndex->index('driver.email', $email) : null;
$sql = "SELECT
driver.*,
phone_verification.is_verified,
@@ -52,49 +50,49 @@ try {
CarRegistration.model,
CarRegistration.year
FROM driver
LEFT JOIN phone_verification ON phone_verification.phone_number = driver.phone
LEFT JOIN phone_verification ON phone_verification.phone_number = driver.phone_key
LEFT JOIN CarRegistration ON CarRegistration.driverID = driver.id
WHERE
driver.email = :email
LIMIT 1";
WHERE driver.email = :email";
$params = [':email' => $encryptedEmail];
if ($emailBidx !== null) {
$sql .= " OR driver.email_bidx = :email_bidx";
$params[':email_bidx'] = $emailBidx;
}
$sql .= " LIMIT 1";
$stmt = $con->prepare($sql);
$stmt->bindParam(':email', $encryptedEmail);
$stmt->execute();
$stmt->execute($params);
$data = $stmt->fetch(PDO::FETCH_ASSOC);
if ($data) {
// التحقق من أن الحساب معلم كحساب فحص في قاعدة البيانات أو البيئة
$isTestInDb = (isset($data['is_test']) && $data['is_test'] == 1) || (isset($data['isTest']) && $data['isTest'] == 1);
if (!$isTestInDb && !$isTester) {
jsonError("Access denied. Not a tester account.");
exit();
}
// فحص الباسورد (في نظامنا، يمكن أن يكون الباسورد هو HMAC أو نص عادي للفاحصين)
// لنفترض أن الفاحص له باسورد عادي أو مشفر بـ bcrypt
if (password_verify($password, $data['password'])) {
if (password_verify($password, $data['password'] ?? '')) {
unset($data['password']);
// فك تشفير الحقول الحساسة
$data['phone'] = $encryptionHelper->decryptData($data['phone']);
$data['email'] = $encryptionHelper->decryptData($data['email']);
$data['gender'] = $encryptionHelper->decryptData($data['gender']);
$data['birthdate'] = $encryptionHelper->decryptData($data['birthdate']);
$data['site'] = $encryptionHelper->decryptData($data['site']);
$data['first_name'] = $encryptionHelper->decryptData($data['first_name']);
$data['last_name'] = $encryptionHelper->decryptData($data['last_name']);
if(isset($data['employmentType'])) $data['employmentType'] = $encryptionHelper->decryptData($data['employmentType']);
if(isset($data['maritalStatus'])) $data['maritalStatus'] = $encryptionHelper->decryptData($data['maritalStatus']);
if(isset($data['phone'])) $data['phone'] = $encryptionHelper->decryptData($data['phone']);
if(isset($data['email'])) $data['email'] = $encryptionHelper->decryptData($data['email']);
if(isset($data['gender'])) $data['gender'] = $encryptionHelper->decryptData($data['gender']);
if(isset($data['birthdate'])) $data['birthdate'] = $encryptionHelper->decryptData($data['birthdate']);
if(isset($data['site'])) $data['site'] = $encryptionHelper->decryptData($data['site']);
if(isset($data['first_name'])) $data['first_name'] = $encryptionHelper->decryptData($data['first_name']);
if(isset($data['last_name'])) $data['last_name'] = $encryptionHelper->decryptData($data['last_name']);
// توليد الـ JWT بصلاحية (tester) لتميزهم عن السائقين الفعليين
$jwtService = new JwtService($redis);
$jwt = $jwtService->generateAccessToken($data['id'], 'tester', $audience, $fingerprint);
echo json_encode([
"status" => "success",
"jwt" => $jwt,
"data" => [$data] // مطابق لنسق التطبيق الذي يتوقع مصفوفة
"data" => [$data]
], JSON_UNESCAPED_UNICODE);
} else {
jsonError("Incorrect password.");
@@ -102,9 +100,9 @@ try {
} else {
jsonError("User does not exist.");
}
} catch (Exception $e) {
error_log("[Tester Login Error] " . $e->getMessage());
jsonError("Server error occurred.");
} catch (Throwable $e) {
error_log("[Tester Login Error] " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine());
jsonError("Server error occurred: " . $e->getMessage() . " in " . basename($e->getFile()) . ":" . $e->getLine());
} finally {
$stmt = null;
$con = null;
+32 -4
View File
@@ -383,6 +383,18 @@ Therefore, do NOT assume a specific field is on the front or the back of a card.
$pwdHashed = password_hash($rawSecret, PASSWORD_DEFAULT);
/* ================== 4) Encrypt sensitive fields ================== */
// فهارس البحث تُحسب من القيم الخام قبل التشفير — بعده تصبح القيمة الأصلية
// غير متاحة، وبعد الانتقال إلى GCM لا يمكن استنتاجها من النص المشفّر.
global $blindIndex;
$phoneBidx = $blindIndex ? $blindIndex->index('driver.phone', $data['phone'] ?? null) : null;
$emailBidx = $blindIndex ? $blindIndex->index('driver.email', $data['email'] ?? null) : null;
$nameBidx = $blindIndex ? $blindIndex->index(
'driver.name',
trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? ''))
) : null;
// مفتاح ربط جداول التحقق — يجب أن يطابق otpPhoneKey() حرفياً
$phoneKey = otpPhoneKey($data['phone'] ?? null);
$toEncryptDriver = [
"phone","email","first_name","last_name","name_arabic","gender",
"national_number","address","site","fullNameMaritial","birthdate"
@@ -402,8 +414,18 @@ Therefore, do NOT assume a specific field is on the front or the back of a card.
$con->beginTransaction();
/* ================== 6) Check duplicate ================== */
$dup = $con->prepare("SELECT id FROM driver WHERE phone = :p OR email = :e");
$dup->execute([':p' => $data['phone'], ':e' => $data['email']]);
$dup = $con->prepare(
"SELECT id FROM driver
WHERE phone = :p OR email = :e
OR (:pb IS NOT NULL AND phone_bidx = :pb)
OR (:eb IS NOT NULL AND email_bidx = :eb)"
);
$dup->execute([
':p' => $data['phone'],
':e' => $data['email'],
':pb' => $phoneBidx,
':eb' => $emailBidx,
]);
if ($dup->rowCount() > 0) {
$con->rollBack();
jsonError("Phone or email already registered.");
@@ -418,14 +440,16 @@ Therefore, do NOT assume a specific field is on the front or the back of a card.
address, licenseIssueDate, status, birthdate, site,
first_name, last_name, accountBank, bankCode,
employmentType, ai_data, user_input, maritalStatus,
fullNameMaritial, expirationDate, created_at, updated_at
fullNameMaritial, expirationDate, created_at, updated_at,
phone_bidx, email_bidx, name_bidx, phone_key
) VALUES (
:id, :phone, :email, :pwd, :gender, :license_type, :national_number,
:name_arabic, :issue_date, :expiry_date, :license_categories,
:address, :licenseIssueDate, :status, :birthdate, :site,
:first_name, :last_name, :accountBank, :bankCode,
:employmentType, :ai_data, :user_input, :maritalStatus,
:fullNameMaritial, :expirationDate, NOW(), NOW()
:fullNameMaritial, :expirationDate, NOW(), NOW(),
:phone_bidx, :email_bidx, :name_bidx, :phone_key
)
";
$insD = $con->prepare($sqlDriver);
@@ -456,6 +480,10 @@ Therefore, do NOT assume a specific field is on the front or the back of a card.
':maritalStatus' => !empty($data['maritalStatus']) ? $data['maritalStatus'] : 'yet',
':fullNameMaritial' => !empty($data['fullNameMaritial']) ? $data['fullNameMaritial'] : 'yet',
':expirationDate' => !empty($data['expirationDate']) ? $data['expirationDate'] : 'yet',
':phone_bidx' => $phoneBidx,
':email_bidx' => $emailBidx,
':name_bidx' => $nameBidx,
':phone_key' => $phoneKey,
]);
if (!$okD) {
$con->rollBack();
+2 -2
View File
@@ -51,8 +51,8 @@ $sentOK = ($httpCode === 200 && ($decoded['success'] ?? false));
if ($sentOK) {
/* 3) تشفير البيانات وحفظها في DB ----------------------------------- */
$receiver_enc = $encryptionHelper->encryptData($receiver);
$otp_enc = $encryptionHelper->encryptData($otp);
$receiver_enc = otpPhoneKey($receiver);
$otp_enc = otpPhoneKey($otp); // يجب أن يطابق صيغة المقارنة في verify_otp
$exp = date('Y-m-d H:i:s', strtotime('+5 minutes'));
$now = date('Y-m-d H:i:s');
+3 -2
View File
@@ -9,8 +9,9 @@ if (empty($phoneNumber) || empty($otp)) {
exit();
}
$phoneNumber_encrypted = $encryptionHelper->encryptData($phoneNumber);
$otp_encrypted = $encryptionHelper->encryptData($otp);
$phoneNumber_encrypted = otpPhoneKey($phoneNumber);
// الرمز يُقارن بالتساوي أيضاً، فيحتاج نفس الصيغة الثابتة
$otp_encrypted = otpPhoneKey($otp);
try {
$stmt = $con->prepare("
+51 -3
View File
@@ -11,16 +11,39 @@ if (empty($phone) && empty($email)) {
exit;
}
// Build WHERE dynamically: support phone-only, email-only, or both
/**
* البحث عن الحساب.
*
* سابقاً كان يقارن القيمة الخام بالعمود المشفّر مباشرةً، وهو ما ينجح فقط لأن
* التشفير الحالي حتمي (CBC بـ IV ثابت). الفهرس الأعمى يجعل هذا الاستعلام
* مستقلاً عن أسلوب التشفير، فلا ينكسر تسجيل الدخول عند الانتقال إلى AES-GCM.
*
* تُبقى المقارنتان القديمتان في نفس الشرط كاحتياط للحسابات التي لم تُفهرس بعد.
*/
global $blindIndex;
$conditions = [];
$params = [':password' => $password];
if (!empty($phone)) {
$conditions[] = "passengers.phone = :phone";
$params[':phone'] = $phone;
$phoneBidx = $blindIndex ? $blindIndex->index('passengers.phone', $phone) : null;
if ($phoneBidx) {
$conditions[] = "passengers.phone_bidx = :phone_bidx";
$params[':phone_bidx'] = $phoneBidx;
}
}
if (!empty($email)) {
$conditions[] = "passengers.email = :email";
$params[':email'] = $email;
$emailBidx = $blindIndex ? $blindIndex->index('passengers.email', $email) : null;
if ($emailBidx) {
$conditions[] = "passengers.email_bidx = :email_bidx";
$params[':email_bidx'] = $emailBidx;
}
}
$where = implode(' OR ', $conditions);
@@ -39,18 +62,43 @@ $sql = "SELECT
passengers.`maritalStatus`,
passengers.`created_at`,
passengers.`updated_at`,
email_verifications.verified
passengers.`email` AS `_email_enc`
FROM
`passengers`
LEFT JOIN email_verifications ON email_verifications.email = passengers.email
WHERE
$where";
/**
* حالة توثيق البريد.
*
* كان الاستعلام يربط email_verifications.email بعمود البريد في الحساب، لكن
* الأول يُخزَّن نصاً صريحاً والثاني مشفّراً — فالربط لم يكن يطابق شيئاً أصلاً
* وكانت verified تعود NULL دائماً. نجلبها هنا بالبريد الأصلي.
*/
function fetchEmailVerified(PDO $con, ?string $plainEmail): ?int
{
if (!$plainEmail) return null;
try {
$st = $con->prepare("SELECT verified FROM email_verifications WHERE email = ? LIMIT 1");
$st->execute([$plainEmail]);
$v = $st->fetchColumn();
return $v === false ? null : (int) $v;
} catch (PDOException $e) {
error_log('[email_verifications] ' . $e->getMessage());
return null;
}
}
$stmt = $con->prepare($sql);
$stmt->execute($params);
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
$count = $stmt->rowCount();
if ($count > 0) {
$plainEmail = $encryptionHelper->decryptData($data[0]['_email_enc'] ?? null) ?: null;
$data[0]['verified'] = fetchEmailVerified($con, $plainEmail);
unset($data[0]['_email_enc']);
$stored_password = $data[0]['password'];
if (password_verify($password, $stored_password)) {
unset($data[0]['password']);
+16 -9
View File
@@ -119,7 +119,10 @@ switch (strtolower($country)) {
// 6. DB Storage on Success
if ($sentSuccessfully) {
$encryptedPhone = $encryptionHelper->encryptData($receiver); // Deterministic CBC
$encryptedPhone = otpPhoneKey($receiver); // مفتاح بحث ثابت مستقل عن نمط التشفير
// نسخة قابلة للاسترجاع: خدمة العملاء تتابع من طلب رمزاً ولم يُكمل تسجيله،
// والمفتاح أعلاه أحادي الاتجاه فلا يُستخرج منه الرقم.
$phoneEncStored = $encryptionHelper->encryptData($receiver);
$encryptedOtp = $encryptionHelper->encryptDataGCM($otp); // Random GCM
$encryptedEmail = !empty($email) ? $encryptionHelper->encryptData($email) : '';
@@ -135,11 +138,12 @@ if ($sentSuccessfully) {
$stmtIns = $con->prepare("
INSERT INTO `phone_verification_service`
(`phone_number`, `token_code`, `expiration_time`, `is_verified`, `created_at`)
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
(`phone_number`, `phone_enc`, `token_code`, `expiration_time`, `is_verified`, `created_at`)
VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
");
$stmtIns->execute([
$encryptedPhone,
$phoneEncStored,
$encryptedOtp
]);
} elseif ($user_type === 'driver') {
@@ -166,11 +170,12 @@ if ($sentSuccessfully) {
// Insert new attempt
$stmtIns = $con->prepare("
INSERT INTO `phone_verification`
(`phone_number`, `driverId`, `email`, `token_code`, `expiration_time`, `is_verified`, `created_at`)
VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
(`phone_number`, `phone_enc`, `driverId`, `email`, `token_code`, `expiration_time`, `is_verified`, `created_at`)
VALUES (?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
");
$stmtIns->execute([
$encryptedPhone,
$phoneEncStored,
$driverId ?: '',
$encryptedEmail,
$encryptedOtp
@@ -185,11 +190,12 @@ if ($sentSuccessfully) {
// Insert new attempt
$stmtIns = $con->prepare("
INSERT INTO `token_verification`
(`phone_number`, `token`, `expiration_time`, `verified`, `created_at`)
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
(`phone_number`, `phone_enc`, `token`, `expiration_time`, `verified`, `created_at`)
VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
");
$stmtIns->execute([
$encryptedPhone,
$phoneEncStored,
$encryptedOtp
]);
} else {
@@ -200,11 +206,12 @@ if ($sentSuccessfully) {
// Insert new attempt
$stmtIns = $con->prepare("
INSERT INTO `phone_verification_passenger`
(`phone_number`, `token`, `expiration_time`, `verified`, `created_at`)
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
(`phone_number`, `phone_enc`, `token`, `expiration_time`, `verified`, `created_at`)
VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
");
$stmtIns->execute([
$encryptedPhone,
$phoneEncStored,
$encryptedOtp
]);
}
+9 -5
View File
@@ -59,7 +59,7 @@ try {
// 3. Encrypt data to query
// 4. Verify based on user type
try {
$encryptedPhoneSearch = $encryptionHelper->encryptData($phone_number);
$encryptedPhoneSearch = otpPhoneKey($phone_number);
if ($user_type === 'admin') {
$sql = "SELECT * FROM token_verification_admin
@@ -177,8 +177,10 @@ try {
$isRegistered = false;
$driverData = null;
$chkStmt = $con->prepare("SELECT id, first_name, last_name, email, phone FROM driver WHERE phone = ?");
$chkStmt->execute([$encryptionHelper->encryptData($phone_number)]);
$chkStmt = $con->prepare("SELECT id, first_name, last_name, email, phone FROM driver WHERE phone = ? OR (? IS NOT NULL AND phone_bidx = ?)");
global $blindIndex;
$phoneBidx = $blindIndex ? $blindIndex->index('driver.phone', $phone_number) : null;
$chkStmt->execute([$encryptionHelper->encryptData($phone_number), $phoneBidx, $phoneBidx]);
$driver = $chkStmt->fetch(PDO::FETCH_ASSOC);
// Generate driverID for unregistered users (hash of phone)
@@ -273,8 +275,10 @@ try {
$passengerData = null;
$passengerID = '';
$chkStmt = $con->prepare("SELECT id, first_name, last_name, email, phone FROM passengers WHERE phone = ?");
$chkStmt->execute([$encryptionHelper->encryptData($phone_number)]);
$chkStmt = $con->prepare("SELECT id, first_name, last_name, email, phone FROM passengers WHERE phone = ? OR (? IS NOT NULL AND phone_bidx = ?)");
global $blindIndex;
$phoneBidx = $blindIndex ? $blindIndex->index('passengers.phone', $phone_number) : null;
$chkStmt->execute([$encryptionHelper->encryptData($phone_number), $phoneBidx, $phoneBidx]);
$passenger = $chkStmt->fetch(PDO::FETCH_ASSOC);
if ($passenger) {
+1 -1
View File
@@ -45,7 +45,7 @@ $sql = "SELECT
t.fingerPrint AS fcm_fingerprint
FROM passengers p
LEFT JOIN phone_verification_passenger
ON phone_verification_passenger.phone_number = p.phone
ON phone_verification_passenger.phone_number = p.phone_key
LEFT JOIN invitesToPassengers
ON invitesToPassengers.inviterPassengerPhone = p.phone
LEFT JOIN promos
@@ -9,18 +9,16 @@ $password = filterRequest("password");
$fingerprint = filterRequest('fingerPrint') ?? filterRequest('fingerprint');
$audience = filterRequest('aud') ?: 'siro_passenger';
// 1. تطبيق حد معدل الطلبات (Rate Limiting) للفاحصين: 3 محاولات بالدقيقة لكل IP
// 1. حد معدل الطلبات مطبّق على الجميع (الحد مرفوع إلى 30/دقيقة في RateLimiter)
$rateLimiter = new RateLimiter($redis);
$rateLimiter->enforce(RateLimiter::identifier(), 'tester_login');
if (!$email || !$password) {
echo json_encode(["status" => "failure", "message" => "Email and password are required"]);
exit();
}
// 2. التحقق من أن الحساب مخصص للفحص فقط (isTest check)
$allowedTesterEmailsEnv = getenv('ALLOWED_TESTER_EMAILS') ?: '';
$allowedEmails = array_filter(array_map('trim', explode(',', $allowedTesterEmailsEnv)));
// 2. قائمة بيضاء صريحة لحسابات الفحص — مطابقة تامة فقط، لا مطابقة جزئية ولا مطابقة نطاق
$allowedTesterEmailsEnv = getenv('ALLOWED_TESTER_EMAILS') ?: ($_ENV['ALLOWED_TESTER_EMAILS'] ?? '');
$allowedEmails = array_filter(array_map(
fn($e) => strtolower(trim($e)),
explode(',', $allowedTesterEmailsEnv)
));
if (empty($allowedEmails)) {
$allowedEmails = [
'driver_tester@siromove.com',
@@ -28,18 +26,21 @@ if (empty($allowedEmails)) {
];
}
$cleanEmail = strtolower(trim((string) $email));
$isTester = in_array($cleanEmail, $allowedEmails, true);
$cleanEmail = strtolower(trim($email));
$isTester = in_array($cleanEmail, $allowedEmails) ||
substr($cleanEmail, -13) === '@siromove.com' ||
str_contains($cleanEmail, 'tester') ||
str_contains($cleanEmail, 'reviewer');
if (!$email || !$password) {
echo json_encode(["status" => "failure", "message" => "Email and password are required"]);
exit();
}
try {
$con = Database::get('main');
// تشفير الإيميل للبحث في قاعدة البيانات
$encryptedEmail = $encryptionHelper->encryptData($email);
global $blindIndex;
$emailBidx = $blindIndex ? $blindIndex->index('passengers.email', $email) : null;
// Auto-seed/create tester passenger logic removed for security
@@ -51,14 +52,15 @@ try {
invitesToPassengers.isGiftToken
FROM passengers p
LEFT JOIN phone_verification_passenger
ON phone_verification_passenger.phone_number = p.phone
ON phone_verification_passenger.phone_number = p.phone_key
LEFT JOIN invitesToPassengers
ON invitesToPassengers.inviterPassengerPhone = p.phone
WHERE p.email = :email
WHERE p.email = :email OR (:email_bidx IS NOT NULL AND p.email_bidx = :email_bidx)
LIMIT 1";
$stmt = $con->prepare($sql);
$stmt->bindParam(':email', $encryptedEmail);
$stmt->bindParam(':email_bidx', $emailBidx);
$stmt->execute();
$data = $stmt->fetch(PDO::FETCH_ASSOC);
@@ -118,7 +120,7 @@ try {
http_response_code(500);
echo json_encode([
"status" => "failure",
"message" => "Server error: " . $e->getMessage() . " in " . basename($e->getFile()) . " on line " . $e->getLine()
"message" => "Server error. Please try again."
]);
}
exit();
+24 -7
View File
@@ -63,7 +63,7 @@ try {
$firstName_encrypted = $encryptionHelper->encryptData($firstName);
$lastName_encrypted = $encryptionHelper->encryptData($lastName);
$email_encrypted = $encryptionHelper->encryptData($email);
$uniqueId = substr(md5($phoneNumber), 0, 20);
$uniqueId = substr(md5($phoneNumber), 0, 16);
$password_hashed = password_hash($email . $uniqueId, PASSWORD_DEFAULT);
$unknown_encrypted = $encryptionHelper->encryptData("unknown yet");
@@ -77,12 +77,13 @@ try {
// (مهلة أوسع من صلاحية الرمز نفسه [5 دقائق] لإعطاء وقت كافٍ لإكمال
// نموذج التسجيل بعد التحقق مباشرة).
$step = 4.5;
$otpSearchKey = otpPhoneKey($phoneNumber);
$verifyCheckStmt = $con->prepare(
"SELECT id FROM phone_verification_passenger
WHERE phone_number = ? AND verified = 1 AND created_at > DATE_SUB(NOW(), INTERVAL 30 MINUTE)
LIMIT 1"
);
$verifyCheckStmt->execute([$phoneNumber_encrypted]);
$verifyCheckStmt->execute([$otpSearchKey]);
if ($verifyCheckStmt->rowCount() === 0) {
error_log("$logTag Step 4.5 Error: Phone number not verified via OTP.");
jsonError("Phone number must be verified before registration.");
@@ -103,8 +104,19 @@ try {
// Step 6: التحقق من وجود المستخدم (Database Check)
// ======================================================
$step = 6;
$checkStmt = $con->prepare("SELECT id FROM passengers WHERE phone = ?");
$checkStmt->execute([$phoneNumber_encrypted]);
// كشف التكرار عبر الفهرس الأعمى + المقارنة القديمة: بدون الفهرس يفشل
// الكشف بعد الانتقال إلى GCM فيُسمح بتسجيل نفس الرقم مرتين.
global $blindIndex;
$phoneBidx = $blindIndex ? $blindIndex->index('passengers.phone', $phoneNumber) : null;
$emailBidx = $blindIndex ? $blindIndex->index('passengers.email', $email) : null;
$nameBidx = $blindIndex ? $blindIndex->index('passengers.name', trim("$firstName $lastName")) : null;
// مفتاح ربط جداول التحقق — يجب أن يطابق otpPhoneKey() حرفياً
$phoneKey = otpPhoneKey($phoneNumber);
$checkStmt = $con->prepare(
"SELECT id FROM passengers WHERE phone = ? OR (? IS NOT NULL AND phone_bidx = ?)"
);
$checkStmt->execute([$phoneNumber_encrypted, $phoneBidx, $phoneBidx]);
if ($checkStmt->rowCount() > 0) {
error_log("$logTag Step 6 Error: User already exists.");
@@ -119,8 +131,8 @@ try {
error_log("$logTag Step 7: Inserting into passengers table...");
$insertStmt = $con->prepare("
INSERT INTO passengers (id, first_name, last_name, email, phone, password, gender, birthdate, site, sosPhone, education, employmentType, maritalStatus, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NOW(), NOW())
INSERT INTO passengers (id, first_name, last_name, email, phone, password, gender, birthdate, site, sosPhone, education, employmentType, maritalStatus, status, created_at, updated_at, phone_bidx, email_bidx, name_bidx, phone_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NOW(), NOW(), ?, ?, ?, ?)
");
$success = $insertStmt->execute([
$uniqueId,
@@ -135,7 +147,12 @@ try {
$unknown_encrypted,
$unknown_encrypted,
$unknown_encrypted,
$unknown_encrypted
$unknown_encrypted,
// فهارس البحث: تُكتب مع السجل حتى يكون قابلاً للبحث فوراً
$phoneBidx,
$emailBidx,
$nameBidx,
$phoneKey
]);
if (!$success) {
+2 -2
View File
@@ -51,8 +51,8 @@ $sentOK = ($httpCode === 200 && ($decoded['success'] ?? false));
if ($sentOK) {
/* 3) حفظ الرمز في Redis + قاعدة البيانات */
$receiver_enc = $encryptionHelper->encryptData($receiver);
$otp_enc = $encryptionHelper->encryptData($otp);
$receiver_enc = otpPhoneKey($receiver);
$otp_enc = otpPhoneKey($otp); // يجب أن يطابق صيغة المقارنة في verify_otp
$exp = date('Y-m-d H:i:s', strtotime('+5 minutes'));
$now = date('Y-m-d H:i:s');
+3 -2
View File
@@ -18,8 +18,9 @@ if (empty($phoneNumber) || empty($otp)) {
exit();
}
$phoneNumber_encrypted = $encryptionHelper->encryptData($phoneNumber);
$otp_encrypted = $encryptionHelper->encryptData($otp);
$phoneNumber_encrypted = otpPhoneKey($phoneNumber);
// الرمز يُقارن بالتساوي أيضاً، فيحتاج نفس الصيغة الثابتة
$otp_encrypted = otpPhoneKey($otp);
try {
// 1. التحقق من Redis بدلاً من MySQL
+7 -9
View File
@@ -4,9 +4,8 @@ require_once __DIR__ . '/../connect.php';
$email = filterRequest("email");
$token = filterRequest("token");
$sql = "SELECT * FROM `email_verifications` WHERE `email` = '$email'";
$stmt = $con->prepare($sql);
$stmt->execute();
$stmt = $con->prepare("SELECT * FROM `email_verifications` WHERE `email` = ?");
$stmt->execute([$email]);
$rowCount = $stmt->rowCount();
@@ -41,9 +40,9 @@ SEFER Team.
if ($rowCount > 0) {
// The email already exists, so update the data
$sql = "UPDATE `email_verifications` SET `token` = '$token' WHERE `email` = '$email'";
$stmt = $con->prepare($sql);
$stmt->execute();
// كانت القيم تُدمج في نص الاستعلام مباشرةً — حقن SQL عبر البريد أو الرمز.
$stmt = $con->prepare("UPDATE `email_verifications` SET `token` = ? WHERE `email` = ?");
$stmt->execute([$token, $email]);
if ($stmt->rowCount() > 0) {
// The update was successful
@@ -55,9 +54,8 @@ if ($rowCount > 0) {
}
} else {
// The email does not exist, so insert the data
$sql = "INSERT INTO `email_verifications` (`email`, `token`) VALUES ('$email', '$token')";
$stmt = $con->prepare($sql);
$stmt->execute();
$stmt = $con->prepare("INSERT INTO `email_verifications` (`email`, `token`) VALUES (?, ?)");
$stmt->execute([$email, $token]);
if ($stmt->rowCount() > 0) {
// The insertion was successful
+3 -3
View File
@@ -1,12 +1,12 @@
{
"require": {
"vlucas/phpdotenv": "^5.6",
"firebase/php-jwt": "^6.0"
"firebase/php-jwt": "^7.0"
},
"prefer-stable": true,
"config": {
"audit": {
"ignore": ["PKSA-y2cr-5h3j-g3ys"]
"platform": {
"php": "8.2"
}
}
}
+21 -15
View File
@@ -4,20 +4,20 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "e192df06759c90826eeb518a1ea5f0c8",
"content-hash": "e5589fd14ce83adda13b8b9cebed44fa",
"packages": [
{
"name": "firebase/php-jwt",
"version": "v6.11.1",
"version": "v7.1.0",
"source": {
"type": "git",
"url": "https://github.com/googleapis/php-jwt.git",
"reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66"
"reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66",
"reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66",
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0",
"reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0",
"shasum": ""
},
"require": {
@@ -25,6 +25,8 @@
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpfastcache/phpfastcache": "^9.2",
"phpseclib/phpseclib": "~3.0",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
@@ -33,7 +35,8 @@
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present",
"phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures"
},
"type": "library",
"autoload": {
@@ -58,16 +61,16 @@
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"homepage": "https://github.com/googleapis/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/googleapis/php-jwt/issues",
"source": "https://github.com/googleapis/php-jwt/tree/v6.11.1"
"source": "https://github.com/googleapis/php-jwt/tree/v7.1.0"
},
"time": "2025-04-09T20:32:01+00:00"
"time": "2026-06-11T17:54:14+00:00"
},
{
"name": "graham-campbell/result-type",
@@ -460,16 +463,16 @@
},
{
"name": "vlucas/phpdotenv",
"version": "v5.6.3",
"version": "v5.6.4",
"source": {
"type": "git",
"url": "https://github.com/vlucas/phpdotenv.git",
"reference": "955e7815d677a3eaa7075231212f2110983adecc"
"reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc",
"reference": "955e7815d677a3eaa7075231212f2110983adecc",
"url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b",
"reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b",
"shasum": ""
},
"require": {
@@ -528,7 +531,7 @@
],
"support": {
"issues": "https://github.com/vlucas/phpdotenv/issues",
"source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3"
"source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4"
},
"funding": [
{
@@ -540,7 +543,7 @@
"type": "tidelift"
}
],
"time": "2025-12-27T19:49:13+00:00"
"time": "2026-07-06T19:11:50+00:00"
}
],
"packages-dev": [],
@@ -551,5 +554,8 @@
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"platform-overrides": {
"php": "8.2"
},
"plugin-api-version": "2.9.0"
}
+12 -3
View File
@@ -156,7 +156,9 @@ class JwtService
// 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'));
// التفاصيل في اللوج فقط — لا تُكشف في الرد.
error_log('[SECURITY] Issuer mismatch | expected: ' . $this->issuer . ' | got: ' . ($decoded->iss ?? 'none'));
self::abort(401, 'Invalid token issuer');
}
// 3.1 App Signature Verification (Service Only)
@@ -228,8 +230,15 @@ class JwtService
}
if ($fpInToken === null || $fpHeader === null) {
$allHeaders = json_encode(getallheaders());
error_log("[SECURITY] Fingerprint missing | user: $userId | fpInToken: " . ($fpInToken ?? 'NULL') . " | fpHeader: " . ($fpHeader ?? 'NULL') . " | Headers: $allHeaders");
// ملاحظة: ممنوع تسجيل الهيدرز كاملة — كانت تُسرّب الـ
// Authorization: Bearer <token> بالنص الصريح إلى error_log.
// نسجّل فقط أيّ الطرفين ناقص، دون أي قيمة.
error_log(sprintf(
"[SECURITY] Fingerprint missing | user: %s | inToken: %s | inHeader: %s",
$userId,
$fpInToken === null ? 'no' : 'yes',
$fpHeader === null ? 'no' : 'yes'
));
self::abort(403, 'Device verification required');
}
+1 -1
View File
@@ -11,7 +11,7 @@ class RateLimiter
// حدود مختلفة لكل نوع endpoint
private const LIMITS = [
'login' => ['requests' => 5, 'window' => 60], // 5 محاولات / دقيقة
'tester_login' => ['requests' => 3, 'window' => 60], // 3 محاولات / دقيقة
'tester_login' => ['requests' => 30, 'window' => 60], // 30 محاولة / دقيقة (مراجعو المتاجر يكرّرون الدخول بسرعة)
'otp' => ['requests' => 3, 'window' => 300], // 3 محاولات / 5 دقائق
'register' => ['requests' => 3, 'window' => 3600], // 3 محاولات / ساعة
'api' => ['requests' => 180, 'window' => 60], // 180 طلب / دقيقة (الإنتاج الرسمى)
+6
View File
@@ -34,6 +34,12 @@ class Database
'user' => 'DB_TRANSIT_USER',
'pass' => 'DB_TRANSIT_PASS',
],
'food' => [
'name' => 'DB_FOOD_NAME',
'host' => 'DB_FOOD_HOST',
'user' => 'DB_FOOD_USER',
'pass' => 'DB_FOOD_PASS',
],
];
public static function get(string $name = 'main'): PDO
+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);
}
}
+35 -2
View File
@@ -14,7 +14,16 @@ class EncryptionHelper
private const TAG_LEN = 16;
private const PREFIX_GCM = 'GCM:'; // للتمييز بين الجديد والقديم
public function __construct(string $key, ?string $cbcIv = null)
/**
* وضع الكتابة: '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.');
@@ -22,10 +31,34 @@ class EncryptionHelper
$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';
}
// ─── تشفير نص باستخدام AES-256-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);
+16 -2
View File
@@ -42,7 +42,9 @@ header("X-XSS-Protection: 1; mode=block");
// CORS مع التحقق من المصدر المسموح
$allowedOrigins = array_map('trim', explode(',', getenv('CORS_ALLOWED_ORIGINS') ?: 'https://siromove.com,https://admin.siromove.com'));
$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");
@@ -51,7 +53,8 @@ if (in_array($origin, $allowedOrigins)) {
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');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
// REQUEST_METHOD غير معرّف عند التشغيل من سطر الأوامر (سكربتات الترحيل)
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(200);
exit;
}
@@ -157,6 +160,17 @@ try {
// 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';
+25
View File
@@ -38,6 +38,31 @@ function filterRequest(string $name, string $type = 'string'): mixed
};
}
/**
* مفتاح بحث ثابت لجداول التحقق (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
{
+40
View File
@@ -0,0 +1,40 @@
<?php
$files = [
'loginAdmin.php',
'login.php',
'loginFirstTimeDriver.php',
'loginWallet.php',
'ride/rides/get_driver_location.php',
'ride/rides/public_track_location.php',
'ride/location/getUpdatedLocationForAdmin.php',
'Admin/jwtService.php',
'Admin/rides/get_driver_live_pos.php',
'Admin/rides/get_rides_by_status.php',
'loginFirstTime.php',
'loginJwtDriver.php'
];
foreach ($files as $file) {
$fullPath = __DIR__ . '/' . $file;
if (!file_exists($fullPath)) continue;
$content = file_get_contents($fullPath);
// Remove variations of the CORS header
$content = preg_replace('/header\s*\(\s*[\'"]Access-Control-Allow-Origin:[^\'"]*[\'"]\s*\)\s*;/i', '', $content);
// Remove manually added Access-Control-Allow-Methods and Headers since bootstrap.php does it
$content = preg_replace('/header\s*\(\s*[\'"]Access-Control-Allow-Methods:[^\'"]*[\'"]\s*\)\s*;/i', '', $content);
$content = preg_replace('/header\s*\(\s*[\'"]Access-Control-Allow-Headers:[^\'"]*[\'"]\s*\)\s*;/i', '', $content);
// Remove manually added Access-Control-Allow-Credentials
$content = preg_replace('/header\s*\(\s*[\'"]Access-Control-Allow-Credentials:[^\'"]*[\'"]\s*\)\s*;/i', '', $content);
// Remove the OPTIONS check since bootstrap.php handles it
$content = preg_replace('/if\s*\(\$_SERVER\[\'REQUEST_METHOD\'\]\s*===\s*\'OPTIONS\'\)\s*\{\s*http_response_code\(\d+\);\s*exit;\s*\}/i', '', $content);
// Also remove any remaining Access-Control headers in variables (like the one in loginAdmin)
$content = preg_replace('/if\s*\(\$isLocal\s*\|\|\s*in_array\(\$requestOrigin,\s*\$allowedOrigins,\s*true\)\)\s*\{[^}]+\}\s*else\s*\{[^}]+\}/i', '', $content);
// Some files check if $_SERVER['REQUEST_METHOD'] == 'OPTIONS' in different format
$content = preg_replace('/if\s*\(\s*\$_SERVER\[\'REQUEST_METHOD\'\]\s*==\s*\'OPTIONS\'\s*\)\s*\{\s*http_response_code\(\d+\);\s*exit;\s*\}/i', '', $content);
file_put_contents($fullPath, $content);
echo "Fixed $file\n";
}
+23
View File
@@ -0,0 +1,23 @@
<?php
// food/admin/merchant_approve.php — اعتماد أو رفض مطعم بحالة pending_approval
require_once __DIR__ . '/../connect_admin.php';
$merchantId = filterRequest('merchant_id', 'int');
$action = filterRequest('action'); // 'approve' | 'reject'
if (!$merchantId || !in_array($action, ['approve', 'reject'], true)) {
jsonError('merchant_id and action (approve|reject) are required');
}
$st = $food_con->prepare("SELECT id, status FROM food_merchants WHERE id=? LIMIT 1");
$st->execute([$merchantId]);
$merchant = $st->fetch();
if (!$merchant) jsonError('Merchant not found', 404);
if ($merchant['status'] !== 'pending_approval') jsonError('Merchant is not pending approval', 409);
$newStatus = $action === 'approve' ? 'active' : 'rejected';
$food_con->prepare(
"UPDATE food_merchants SET status=?, approved_by=?, approved_at=NOW() WHERE id=?"
)->execute([$newStatus, $food_admin_id, $merchantId]);
jsonSuccess(['merchant_id' => $merchantId, 'status' => $newStatus]);
+48
View File
@@ -0,0 +1,48 @@
<?php
// food/admin/merchant_create.php — إنشاء مطعم جديد بحالة pending_approval + حساب مالكه
require_once __DIR__ . '/../connect_admin.php';
requireFoodFields(['name_ar', 'city', 'address', 'latitude', 'longitude', 'owner_name', 'owner_phone', 'owner_password']);
$nameAr = filterRequest('name_ar');
$nameEn = filterRequest('name_en');
$city = filterRequest('city');
$address = filterRequest('address');
$lat = filterRequest('latitude', 'float');
$lng = filterRequest('longitude', 'float');
$category = filterRequest('category');
$commission = filterRequest('commission_percent', 'float') ?? (float)(getenv('FOOD_COMMISSION_PERCENT') ?: 15);
$ownerName = filterRequest('owner_name');
$ownerPhone = normalizePhone(filterRequest('owner_phone'));
$ownerPassword = filterRequest('owner_password');
if (strlen($ownerPassword) < 8) jsonError('owner_password must be at least 8 characters');
$dupSt = $food_con->prepare("SELECT id FROM food_merchant_users WHERE phone=? LIMIT 1");
$dupSt->execute([$ownerPhone]);
if ($dupSt->fetch()) jsonError('A merchant account already uses this phone', 409);
$food_con->beginTransaction();
try {
$food_con->prepare(
"INSERT INTO food_merchants
(name_ar, name_en, city, address, latitude, longitude, category, commission_percent, status)
VALUES (?,?,?,?,?,?,?,?,'pending_approval')"
)->execute([$nameAr, $nameEn, $city, $address, $lat, $lng, $category, $commission]);
$merchantId = (int)$food_con->lastInsertId();
$food_con->prepare(
"INSERT INTO food_merchant_users (merchant_id, name, phone, role, password_hash)
VALUES (?,?,?,'owner',?)"
)->execute([$merchantId, $ownerName, $ownerPhone, password_hash($ownerPassword, PASSWORD_DEFAULT)]);
$food_con->commit();
} catch (Throwable $e) {
$food_con->rollBack();
appLog('[FOOD][ADMIN][merchant_create] ' . $e->getMessage(), 'ERROR');
jsonError('Failed to create merchant', 500);
}
jsonSuccess(['merchant_id' => $merchantId, 'status' => 'pending_approval'], 'Merchant created — awaiting approval');
+22
View File
@@ -0,0 +1,22 @@
<?php
// food/admin/merchants.php — قائمة المطاعم لإدارة سيرو (فلترة حسب الحالة)
require_once __DIR__ . '/../connect_admin.php';
$status = filterRequest('status') ?: 'all';
$allowed = ['all', 'pending_approval', 'active', 'paused', 'suspended', 'rejected'];
if (!in_array($status, $allowed, true)) $status = 'all';
$sql = "SELECT id, name_ar, name_en, city, category, status, commission_percent,
rating_avg, rating_count, created_at, approved_at
FROM food_merchants";
$params = [];
if ($status !== 'all') {
$sql .= " WHERE status=?";
$params[] = $status;
}
$sql .= " ORDER BY created_at DESC";
$st = $food_con->prepare($sql);
$st->execute($params);
jsonSuccess(['merchants' => $st->fetchAll()]);
+39
View File
@@ -0,0 +1,39 @@
<?php
// food/admin/payouts.php — تقرير تسويات المطاعم للفترة المطلوبة (توليد لا صرف آلي)
//
// ⚠️ هذا يُنتج تقريراً محاسبياً (food_merchant_payouts بحالة pending) — لا يحوّل
// أموالاً فعلياً. الصرف الفعلي للمطاعم والسائقين خارج نطاق هذه الوحدة حتى يُؤكَّد
// عقد S2S مخصص لذلك (انظر التنبيهات في food/functions.php حول foodWalletMove).
require_once __DIR__ . '/../connect_admin.php';
$merchantId = filterRequest('merchant_id', 'int');
$periodStart = filterRequest('period_start');
$periodEnd = filterRequest('period_end');
requireFoodFields(['merchant_id', 'period_start', 'period_end']);
$st = $food_con->prepare(
"SELECT COUNT(*) orders_count, COALESCE(SUM(items_total),0) gross_amount, COALESCE(SUM(commission_amount),0) commission_amount
FROM food_orders
WHERE merchant_id=? AND status='delivered' AND delivered_at BETWEEN ? AND ?"
);
$st->execute([$merchantId, $periodStart, $periodEnd]);
$summary = $st->fetch();
$netPayout = (int)$summary['gross_amount'] - (int)$summary['commission_amount'];
$food_con->prepare(
"INSERT INTO food_merchant_payouts (merchant_id, period_start, period_end, orders_count, gross_amount, commission_amount, net_payout, status)
VALUES (?,?,?,?,?,?,?,'pending')"
)->execute([
$merchantId, $periodStart, $periodEnd, $summary['orders_count'],
$summary['gross_amount'], $summary['commission_amount'], $netPayout,
]);
jsonSuccess([
'merchant_id' => $merchantId,
'orders_count' => (int)$summary['orders_count'],
'gross_amount' => (int)$summary['gross_amount'],
'commission_amount' => (int)$summary['commission_amount'],
'net_payout' => $netPayout,
'status' => 'pending',
], 'Payout report generated');
+110
View File
@@ -0,0 +1,110 @@
<?php
// food/cart/quote.php — يحسب السعر من الخادم فقط ويوقّعه لمدة 10 دقائق
// body: merchant_id, items:[{item_id, quantity, options:[{option_group_id, choice_ids:[]}]}]
require_once __DIR__ . '/../connect_app.php';
$merchantId = filterRequest('merchant_id', 'int');
$itemsRaw = json_decode(filterRequest('items') ?? '[]', true);
if (!$merchantId || !$itemsRaw || !is_array($itemsRaw)) {
jsonError('merchant_id and items are required');
}
$merchantSt = $food_con->prepare(
"SELECT id, min_order_amount, is_open_override, working_hours FROM food_merchants WHERE id=? AND status='active' LIMIT 1"
);
$merchantSt->execute([$merchantId]);
$merchant = $merchantSt->fetch();
if (!$merchant) jsonError('Merchant not found', 404);
if (!foodIsMerchantOpen($merchant)) jsonError('Merchant is currently closed', 409);
$lines = [];
$itemsTotal = 0;
foreach ($itemsRaw as $line) {
$itemId = (int)($line['item_id'] ?? 0);
$quantity = max(1, (int)($line['quantity'] ?? 1));
if (!$itemId) jsonError('Invalid item in cart');
$itemSt = $food_con->prepare(
"SELECT id, merchant_id, name_ar, price, is_available FROM food_menu_items WHERE id=? LIMIT 1"
);
$itemSt->execute([$itemId]);
$item = $itemSt->fetch();
if (!$item || (int)$item['merchant_id'] !== $merchantId) jsonError("Item $itemId does not belong to this merchant", 400);
if (!$item['is_available']) jsonError("{$item['name_ar']} is currently unavailable", 409);
$optionsSt = $food_con->prepare("SELECT id, choices, is_required, max_select FROM food_item_options WHERE item_id=?");
$optionsSt->execute([$itemId]);
$optionGroups = $optionsSt->fetchAll();
$chosenOptionsOut = [];
$optionsTotalPerUnit = 0;
$requestedGroups = $line['options'] ?? [];
foreach ($optionGroups as $group) {
$choices = json_decode($group['choices'], true) ?: [];
$choiceById = array_column($choices, null, 'id');
$requested = null;
foreach ($requestedGroups as $rg) {
if ((int)($rg['option_group_id'] ?? 0) === (int)$group['id']) { $requested = $rg; break; }
}
$choiceIds = $requested['choice_ids'] ?? [];
if ($group['is_required'] && empty($choiceIds)) {
jsonError("Required option group missing for item {$item['name_ar']}", 400);
}
if (count($choiceIds) > (int)$group['max_select']) {
jsonError("Too many choices selected for item {$item['name_ar']}", 400);
}
foreach ($choiceIds as $cid) {
if (!isset($choiceById[$cid])) jsonError("Invalid option choice for item {$item['name_ar']}", 400);
$optionsTotalPerUnit += (int)$choiceById[$cid]['price'];
$chosenOptionsOut[] = ['group_id' => (int)$group['id'], 'choice_id' => $cid, 'price' => (int)$choiceById[$cid]['price']];
}
}
$unitPrice = (int)$item['price'] + $optionsTotalPerUnit;
$lineTotal = $unitPrice * $quantity;
$itemsTotal += $lineTotal;
$lines[] = [
'item_id' => $itemId,
'name_ar_snapshot' => $item['name_ar'],
'unit_price' => $unitPrice,
'quantity' => $quantity,
'options' => $chosenOptionsOut,
'line_total' => $lineTotal,
];
}
if ($itemsTotal < (int)$merchant['min_order_amount']) {
jsonError('Order does not meet minimum order amount', 409, ['min_order_amount' => (int)$merchant['min_order_amount']]);
}
// رسم التوصيل — ثابت من env حتى يُربط بمحرك التسعير القائم في مرحلة لاحقة
$deliveryFee = (int)(getenv('FOOD_DELIVERY_BASE_FEE') ?: 1000);
$serviceFee = 0;
$grandTotal = $itemsTotal + $deliveryFee + $serviceFee;
$quote = [
'merchant_id' => $merchantId,
'passenger_id' => $food_passenger_id,
'lines' => $lines,
'items_total' => $itemsTotal,
'delivery_fee' => $deliveryFee,
'service_fee' => $serviceFee,
'grand_total' => $grandTotal,
];
$token = foodSignQuote($quote);
jsonSuccess([
'quote_token' => $token,
'items_total' => $itemsTotal,
'delivery_fee' => $deliveryFee,
'service_fee' => $serviceFee,
'grand_total' => $grandTotal,
'lines' => $lines,
'expires_in' => 600,
]);
+28
View File
@@ -0,0 +1,28 @@
<?php
// ============================================================
// food/connect_admin.php — بوابة إدارة سيرو (اعتماد المطاعم، التسويات...)
// يستخدم JWT الإداري نفسه المستخدم في backend/Admin (role admin/super_admin)
// ============================================================
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/functions.php';
$limiter = new RateLimiter($redis);
$limiter->enforce(RateLimiter::identifier(), 'api');
$jwtService = new JwtService($redis);
$decoded = $jwtService->authenticate();
$food_admin_role = $decoded->role ?? '';
if (!in_array($food_admin_role, ['admin', 'super_admin'], true)) {
jsonError('Forbidden — admin token required', 403);
}
$food_admin_id = (string)($decoded->user_id ?? '');
try {
$food_con = Database::get('food');
} catch (Exception $e) {
http_response_code(503);
echo json_encode(['status' => 'failure', 'message' => 'Food service unavailable']);
exit;
}
+37
View File
@@ -0,0 +1,37 @@
<?php
// ============================================================
// food/connect_app.php — بوابة الزبون (تبويب «طعام» داخل siro_rider)
// يستخدم JWT الرئيسي نفسه — الزبون هو الراكب نفسه، لا حساب ثانٍ
// ============================================================
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/../functions.php';
require_once __DIR__ . '/functions.php';
if (getenv('FOOD_ENABLED') === 'false') {
http_response_code(503);
echo json_encode(['status' => 'failure', 'message' => 'Food service is currently disabled']);
exit;
}
// Rate limiting — نفس حد API العادي
$limiter = new RateLimiter($redis);
$limiter->enforce(RateLimiter::identifier(), 'api');
// JWT المعتاد — راكب فقط (لا يُقبل توكن سائق هنا)
$jwtService = new JwtService($redis);
$decoded = $jwtService->authenticate();
if (($decoded->role ?? '') !== 'passenger') {
jsonError('Forbidden — passenger token required', 403);
}
$food_passenger_id = (string)($decoded->user_id ?? '');
// اتصال قاعدة بيانات food فقط — ممنوع Database::get('main') في ملفات food/
try {
$food_con = Database::get('food');
} catch (Exception $e) {
http_response_code(503);
echo json_encode(['status' => 'failure', 'message' => 'Food service unavailable']);
exit;
}
+34
View File
@@ -0,0 +1,34 @@
<?php
// ============================================================
// food/connect_courier.php — بوابة السائق (نوع مهمة «توصيل» داخل تدفّق العروض القائم)
// يستخدم JWT الرئيسي نفسه — السائق هو الكابتن نفسه، لا حساب ثانٍ
// ============================================================
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/../functions.php';
require_once __DIR__ . '/functions.php';
if (getenv('FOOD_ENABLED') === 'false') {
http_response_code(503);
echo json_encode(['status' => 'failure', 'message' => 'Food service is currently disabled']);
exit;
}
$limiter = new RateLimiter($redis);
$limiter->enforce(RateLimiter::identifier(), 'api');
$jwtService = new JwtService($redis);
$decoded = $jwtService->authenticate();
if (($decoded->role ?? '') !== 'driver') {
jsonError('Forbidden — driver token required', 403);
}
$food_courier_id = (string)($decoded->user_id ?? '');
try {
$food_con = Database::get('food');
} catch (Exception $e) {
http_response_code(503);
echo json_encode(['status' => 'failure', 'message' => 'Food service unavailable']);
exit;
}
+41
View File
@@ -0,0 +1,41 @@
<?php
// ============================================================
// food/connect_merchant.php — بوابة لوحة المطعم (ويب متجاوب)
// المصادقة عبر session token (هاتف + كلمة مرور) — مستقلة عن JWT
// ============================================================
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/functions.php';
if (getenv('FOOD_ENABLED') === 'false') {
http_response_code(503);
echo json_encode(['status' => 'failure', 'message' => 'Food service is currently disabled']);
exit;
}
// CORS للوحة المطعم
$merchantOrigins = array_map('trim', explode(',',
getenv('FOOD_MERCHANT_ORIGINS') ?: 'https://food-merchant.siromove.com,https://admin.siromove.com'
));
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $merchantOrigins)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
$limiter = new RateLimiter($redis);
$limiter->enforce(RateLimiter::identifier(), 'api');
try {
$food_con = Database::get('food');
} catch (Exception $e) {
http_response_code(503);
echo json_encode(['status' => 'failure', 'message' => 'Food service unavailable']);
exit;
}
// التحقق من الـ session (يُعيد ['merchant_user_id'=>X, 'merchant_id'=>Y])
$food_merchant_session = foodAuthMerchant();
$food_merchant_user_id = (int)$food_merchant_session['merchant_user_id'];
$food_merchant_id = (int)$food_merchant_session['merchant_id'];
+26
View File
@@ -0,0 +1,26 @@
<?php
// food/courier/active.php — طلبات التوصيل الحالية للسائق (polling fallback + شاشة المهمة)
require_once __DIR__ . '/../connect_courier.php';
$st = $food_con->prepare(
"SELECT o.id, o.status, o.merchant_id, m.name_ar AS merchant_name_ar, m.latitude AS merchant_lat,
m.longitude AS merchant_lng, m.address AS merchant_address, o.delivery_fee,
o.delivery_address, o.delivery_lat, o.delivery_lng, o.created_at,
CASE WHEN o.status IN ('courier_assigned','picked_up') THEN o.delivery_address ELSE NULL END AS visible_address
FROM food_orders o
JOIN food_merchants m ON m.id = o.merchant_id
WHERE o.courier_id = ? AND o.status IN ('courier_assigned','picked_up')
ORDER BY o.courier_assigned_at ASC"
);
$st->execute([$food_courier_id]);
$orders = $st->fetchAll();
// بيانات الزبون (العنوان) تظهر فقط بعد courier_assigned وتُحجب بعد delivered — لا نُعيد أي طلب مسلَّم هنا أصلاً
foreach ($orders as &$o) {
unset($o['delivery_address']);
$o['delivery_address'] = $o['visible_address'];
unset($o['visible_address']);
}
unset($o);
jsonSuccess(['orders' => $orders]);
+34
View File
@@ -0,0 +1,34 @@
<?php
// food/courier/delivered.php — تسليم الطلب — تثبيت المال (capture) وقيد أرباح السائق
require_once __DIR__ . '/../connect_courier.php';
$orderId = filterRequest('order_id', 'int');
if (!$orderId) jsonError('order_id is required');
$order = foodAssertOrderOwnership($orderId, 'courier', $food_courier_id);
food_transition_status($orderId, 'delivered', 'courier', $food_courier_id);
if ($order['payment_method'] === 'wallet') {
// المبلغ خُصم بالكامل عند الإنشاء (انظر ملاحظة foodWalletMove) — capture هنا تسجيل محاسبي فقط
$food_con->prepare(
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'capture',?,'success')"
)->execute([$orderId, (int)$order['grand_total']]);
} else {
// نقداً: السائق حصّل grand_total كاملاً من الزبون. يحتفظ بـ delivery_fee (أجرته)
// ويبقى ديناً عليه الباقي (items_total+service_fee — حصة المطعم والمنصة) حتى يُسوّى
// إدارياً. هذا قيد محاسبي فقط هنا — لا حركة مالية آلية فعلية بعد (انظر التنبيه في
// admin/payouts.php حول عدم وجود عقد API مؤكد لتحصيل ديون السائق النقدية آلياً).
$courierOwed = (int)$order['grand_total'] - (int)$order['delivery_fee'];
$food_con->prepare(
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'cash_settlement',?,'pending')"
)->execute([$orderId, $courierOwed]);
}
// قيد أرباح السائق (أجرة التوصيل) — مُتراكم، يُسوَّى عبر دورة تسوية منفصلة
// (لا نقتطع فعلياً من/إلى محفظة السائق هنا: عقد S2S المؤكد فقط لتحويلات
// سائق↔سائق (driverWallet/transfer.php) لا لإيداع أرباح من المنصة مباشرة).
$food_con->prepare(
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'courier_payout',?,'pending')"
)->execute([$orderId, (int)$order['delivery_fee']]);
jsonSuccess(['order_id' => $orderId, 'status' => 'delivered']);
+76
View File
@@ -0,0 +1,76 @@
<?php
// food/courier/offer_respond.php — قبول/رفض عرض توصيل
// body: order_id, response ('accept'|'reject')
require_once __DIR__ . '/../connect_courier.php';
$orderId = filterRequest('order_id', 'int');
$response = filterRequest('response');
if (!$orderId || !in_array($response, ['accept', 'reject'], true)) {
jsonError('order_id and response (accept|reject) are required');
}
$assignSt = $food_con->prepare(
"SELECT id FROM food_courier_assignments WHERE order_id=? AND courier_id=? AND status='offered'
ORDER BY offered_at DESC LIMIT 1"
);
$assignSt->execute([$orderId, $food_courier_id]);
$assignment = $assignSt->fetch();
if (!$assignment) jsonError('No pending offer for this order', 404);
if ($response === 'reject') {
$food_con->prepare("UPDATE food_courier_assignments SET status='rejected', responded_at=NOW() WHERE id=?")
->execute([$assignment['id']]);
// أعد المحاولة على مرشّح آخر — يعيد استخدام نفس منطق ready.php
$orderSt = $food_con->prepare("SELECT merchant_id FROM food_orders WHERE id=? AND status='ready'");
$orderSt->execute([$orderId]);
if ($order = $orderSt->fetch()) {
$merchantSt = $food_con->prepare("SELECT latitude, longitude FROM food_merchants WHERE id=?");
$merchantSt->execute([$order['merchant_id']]);
if ($merchant = $merchantSt->fetch()) {
$candidates = foodFindNearbyCouriers((float)$merchant['latitude'], (float)$merchant['longitude']);
$priorSt = $food_con->prepare("SELECT courier_id FROM food_courier_assignments WHERE order_id=?");
$priorSt->execute([$orderId]);
$prior = array_column($priorSt->fetchAll(), 'courier_id');
foreach ($candidates as $candidateId) {
if (in_array($candidateId, $prior, true)) continue;
foodOfferOrderToCourier($orderId, $candidateId);
break;
}
}
}
jsonSuccess(['order_id' => $orderId, 'response' => 'rejected']);
}
// accept — القفل الذرّي يمنع سباق القبول لو انتهت مهلة سابقة وعُرض على اثنين معاً
if (!foodLockOrderForCourier($orderId, $food_courier_id)) {
$owner = foodOrderLockOwner($orderId);
if ($owner !== $food_courier_id) {
jsonError('Order was already claimed by another courier', 409);
}
}
// الطلب لم يُسند لأي سائق بعد عند هذه النقطة — لا فحص ملكية هنا، فقط فحص الحالة أدناه
$orderSt = $food_con->prepare("SELECT status FROM food_orders WHERE id=? LIMIT 1");
$orderSt->execute([$orderId]);
$orderRow = $orderSt->fetch();
if (!$orderRow || $orderRow['status'] !== 'ready') {
jsonError('Order is no longer available for assignment', 409);
}
$food_con->beginTransaction();
try {
$food_con->prepare("UPDATE food_orders SET courier_id=? WHERE id=?")->execute([$food_courier_id, $orderId]);
$food_con->prepare("UPDATE food_courier_assignments SET status='accepted', responded_at=NOW() WHERE id=?")
->execute([$assignment['id']]);
$food_con->commit();
} catch (Throwable $e) {
$food_con->rollBack();
appLog('[FOOD][COURIER][offer_respond] ' . $e->getMessage(), 'ERROR');
jsonError('Failed to accept offer', 500);
}
food_transition_status($orderId, 'courier_assigned', 'courier', $food_courier_id);
jsonSuccess(['order_id' => $orderId, 'response' => 'accepted']);
+19
View File
@@ -0,0 +1,19 @@
<?php
// food/courier/pending_offers.php — عروض التوصيل المعروضة على هذا السائق حالياً
// (polling fallback — food_socket يدفع 'food_delivery_offer' لحظياً، وهذا احتياطي
// لو انقطع اتصال السوكيت أو كان التطبيق لا يحمل اتصالاً حياً بالسوكيت)
require_once __DIR__ . '/../connect_courier.php';
$st = $food_con->prepare(
"SELECT a.order_id, a.offered_at, o.merchant_id, m.name_ar AS merchant_name_ar,
m.latitude AS merchant_lat, m.longitude AS merchant_lng, o.delivery_fee,
o.delivery_lat, o.delivery_lng
FROM food_courier_assignments a
JOIN food_orders o ON o.id = a.order_id
JOIN food_merchants m ON m.id = o.merchant_id
WHERE a.courier_id = ? AND a.status = 'offered' AND a.offered_at > (NOW() - INTERVAL 20 SECOND)
ORDER BY a.offered_at ASC"
);
$st->execute([$food_courier_id]);
jsonSuccess(['offers' => $st->fetchAll()]);
+11
View File
@@ -0,0 +1,11 @@
<?php
// food/courier/picked_up.php — السائق استلم الطلب من المطعم
require_once __DIR__ . '/../connect_courier.php';
$orderId = filterRequest('order_id', 'int');
if (!$orderId) jsonError('order_id is required');
foodAssertOrderOwnership($orderId, 'courier', $food_courier_id);
food_transition_status($orderId, 'picked_up', 'courier', $food_courier_id);
jsonSuccess(['order_id' => $orderId, 'status' => 'picked_up']);
@@ -0,0 +1,11 @@
<?php
// food/courier/toggle_availability.php — تفعيل/إيقاف "وضع التوصيل" للسائق
// السائق لا يظهر لعروض التوصيل إلا إذا كان أيضاً متاحاً فعلياً في geo:drivers:available
require_once __DIR__ . '/../connect_courier.php';
$enable = filterRequest('enabled', 'bool');
if ($enable === null) jsonError('enabled (true|false) is required');
foodCourierOptIn($food_courier_id, $enable);
jsonSuccess(['courier_id' => $food_courier_id, 'delivery_mode_enabled' => $enable]);
+65
View File
@@ -0,0 +1,65 @@
<?php
// food/cron_order_timeouts.php — يُشغَّل كل دقيقة من crontab المضيف (مثل cron_* في transit)
// 1) عروض توصيل معروضة أكثر من 20 ثانية بلا رد → timed_out + إعادة العرض لمرشح آخر
// 2) طلبات pending أكثر من 5 دقائق بلا رد المطعم → إلغاء نظامي + استرجاع فوري
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/functions.php';
try { $food_con = Database::get('food'); }
catch (Exception $e) { error_log('[FOOD][CRON] DB unavailable: ' . $e->getMessage()); exit(1); }
// ── 1) عروض منتهية المهلة ──
$expiredSt = $food_con->query(
"SELECT id, order_id, courier_id FROM food_courier_assignments
WHERE status='offered' AND offered_at < (NOW() - INTERVAL 20 SECOND)"
);
foreach ($expiredSt->fetchAll() as $assignment) {
$food_con->prepare("UPDATE food_courier_assignments SET status='timed_out', responded_at=NOW() WHERE id=?")
->execute([$assignment['id']]);
$orderSt = $food_con->prepare("SELECT merchant_id FROM food_orders WHERE id=? AND status='ready'");
$orderSt->execute([$assignment['order_id']]);
$order = $orderSt->fetch();
if (!$order) continue;
$merchantSt = $food_con->prepare("SELECT latitude, longitude FROM food_merchants WHERE id=?");
$merchantSt->execute([$order['merchant_id']]);
$merchant = $merchantSt->fetch();
if (!$merchant) continue;
$candidates = foodFindNearbyCouriers((float)$merchant['latitude'], (float)$merchant['longitude']);
$priorSt = $food_con->prepare("SELECT courier_id FROM food_courier_assignments WHERE order_id=?");
$priorSt->execute([$assignment['order_id']]);
$prior = array_column($priorSt->fetchAll(), 'courier_id');
foreach ($candidates as $candidateId) {
if (in_array($candidateId, $prior, true)) continue;
foodOfferOrderToCourier((int)$assignment['order_id'], $candidateId);
error_log("[FOOD][CRON] Re-offered order {$assignment['order_id']} to courier {$candidateId} after timeout");
break;
}
}
// ── 2) طلبات pending تجاوزت مهلة رد المطعم ──
$stalePendingSt = $food_con->query(
"SELECT id, passenger_id, payment_method, grand_total FROM food_orders
WHERE status='pending' AND created_at < (NOW() - INTERVAL 5 MINUTE)"
);
foreach ($stalePendingSt->fetchAll() as $order) {
food_transition_status((int)$order['id'], 'cancelled_system', 'system', 'cron', 'Merchant did not respond within timeout');
if ($order['payment_method'] === 'wallet') {
$refunded = foodWalletMove(
(string)$order['passenger_id'], (int)$order['grand_total'], 'add',
"food-refund-{$order['id']}", "Food order #{$order['id']} auto-cancelled (merchant timeout)"
);
$food_con->prepare(
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'release',?,?)"
)->execute([$order['id'], (int)$order['grand_total'], $refunded ? 'success' : 'failed']);
if (!$refunded) error_log("[FOOD][CRON] refund FAILED for auto-cancelled order {$order['id']} — needs manual reconciliation");
}
}
echo "food cron_order_timeouts done\n";
+472
View File
@@ -0,0 +1,472 @@
<?php
// ============================================================
// food/functions.php — دوال خاصة بوحدة طلبات الطعام فقط
//
// ما لا يوجد هنا (يُستخدم مباشرة من النظام الأصلي بعد bootstrap):
// • filterRequest() ← core/helpers.php
// • jsonSuccess() / jsonError() ← core/helpers.php
// • appLog() / securityLog() ← core/helpers.php
// • $redis ← مهيَّأ في bootstrap.php
// ============================================================
require_once __DIR__ . '/../core/Services/FcmService.php';
// ── حقول مطلوبة (غلاف رفيع يستخدم filterRequest + jsonError) ──
function requireFoodFields(array $fields): void
{
foreach ($fields as $f) {
if (filterRequest($f) === null) {
jsonError("Missing required field: $f", 400);
}
}
}
// ── ملكية المطعم — يتحقق أن merchant_user يخص هذا المطعم فعلاً ──
function foodAssertMerchantOwnership(int $merchantUserId, int $merchantId): void
{
$con = Database::get('food');
$st = $con->prepare("SELECT id FROM food_merchant_users WHERE id=? AND merchant_id=? AND is_active=1 LIMIT 1");
$st->execute([$merchantUserId, $merchantId]);
if (!$st->fetch()) jsonError('Forbidden', 403);
}
// ── ملكية الطلب — كل نقطة تأخذ order_id يجب أن تتحقق أن الفاعل يملكه ──
// $actorType: customer | merchant | courier
function foodAssertOrderOwnership(int $orderId, string $actorType, string $actorId): array
{
$con = Database::get('food');
$st = $con->prepare("SELECT * FROM food_orders WHERE id=? LIMIT 1");
$st->execute([$orderId]);
$order = $st->fetch();
if (!$order) jsonError('Order not found', 404);
$ok = match ($actorType) {
'customer' => (string)$order['passenger_id'] === $actorId,
'courier' => (string)$order['courier_id'] === $actorId,
'merchant' => (string)$order['merchant_id'] === $actorId,
default => false,
};
if (!$ok) {
appLog("[FOOD][IDOR] actor_type=$actorType actor_id=$actorId tried order_id=$orderId", 'WARNING');
jsonError('Forbidden', 403);
}
return $order;
}
// ── آلة الحالة — كل انتقال يمر من هنا فقط، لا UPDATE مبعثر ──
const FOOD_STATUS_TRANSITIONS = [
'pending' => ['merchant_accepted', 'rejected', 'cancelled_by_customer', 'cancelled_system'],
'merchant_accepted' => ['preparing', 'cancelled_by_merchant', 'cancelled_system'],
'preparing' => ['ready', 'cancelled_by_merchant', 'cancelled_system'],
'ready' => ['courier_assigned', 'cancelled_system'],
'courier_assigned' => ['picked_up', 'cancelled_system'],
'picked_up' => ['delivered'],
'delivered' => [],
'rejected' => [],
'cancelled_by_customer' => [],
'cancelled_by_merchant' => [],
'cancelled_system' => [],
];
function food_transition_status(int $orderId, string $toStatus, string $actorType, string $actorId, ?string $note = null): void
{
$con = Database::get('food');
$con->beginTransaction();
try {
$st = $con->prepare("SELECT status FROM food_orders WHERE id=? FOR UPDATE");
$st->execute([$orderId]);
$row = $st->fetch();
if (!$row) { $con->rollBack(); jsonError('Order not found', 404); }
$fromStatus = $row['status'];
$allowed = FOOD_STATUS_TRANSITIONS[$fromStatus] ?? [];
if (!in_array($toStatus, $allowed, true)) {
$con->rollBack();
jsonError("Invalid status transition: $fromStatus -> $toStatus", 409);
}
$timestampColumn = match ($toStatus) {
'merchant_accepted' => 'merchant_accepted_at',
'ready' => 'ready_at',
'courier_assigned' => 'courier_assigned_at',
'picked_up' => 'picked_up_at',
'delivered' => 'delivered_at',
default => str_starts_with($toStatus, 'cancelled') || $toStatus === 'rejected' ? 'cancelled_at' : null,
};
$sql = "UPDATE food_orders SET status=?" . ($timestampColumn ? ", {$timestampColumn}=NOW()" : '') . " WHERE id=?";
$params = [$toStatus, $orderId];
$con->prepare($sql)->execute($params);
$con->prepare(
"INSERT INTO food_order_status_log (order_id, from_status, to_status, actor_type, actor_id, note)
VALUES (?,?,?,?,?,?)"
)->execute([$orderId, $fromStatus, $toStatus, $actorType, $actorId, $note]);
$con->commit();
} catch (Throwable $e) {
if ($con->inTransaction()) $con->rollBack();
appLog('[FOOD][STATUS] ' . $e->getMessage(), 'ERROR');
throw $e;
}
foodNotifyOrderEvent($orderId, $toStatus);
}
// ── إشعارات — سوكيت (internal HTTP push، نفس نمط passenger_socket) + FCM ──
function foodNotifyOrderEvent(int $orderId, string $status): void
{
$con = Database::get('food');
$st = $con->prepare("SELECT passenger_id, merchant_id, courier_id FROM food_orders WHERE id=?");
$st->execute([$orderId]);
$order = $st->fetch();
if (!$order) return;
foodPushToSocket('order_status_update', [
'order_id' => $orderId,
'status' => $status,
'passenger_id' => (string)$order['passenger_id'],
'merchant_id' => (int)$order['merchant_id'],
'courier_id' => $order['courier_id'] ? (string)$order['courier_id'] : null,
]);
$titles = [
'merchant_accepted' => 'المطعم قبل طلبك',
'preparing' => 'جاري تحضير طلبك',
'ready' => 'طلبك جاهز، بانتظار السائق',
'courier_assigned' => 'تم تعيين سائق لتوصيل طلبك',
'picked_up' => 'السائق استلم طلبك وفي الطريق إليك',
'delivered' => 'تم تسليم طلبك، بالهنا والشفا',
'rejected' => 'اعتذر المطعم عن تنفيذ طلبك',
];
if (isset($titles[$status])) {
foodSendNotificationToPassenger((string)$order['passenger_id'], $titles[$status], '', ['type' => 'food_order_' . $status, 'order_id' => (string)$orderId]);
}
}
// ── نداء HTTP داخلي لـ socket_food — نفس نمط broadcast_bus_location في transit ──
function foodPushToSocket(string $action, array $payload): void
{
$url = getenv('FOOD_SOCKET_URL') ?: 'http://socket_food:4041';
$key = getInternalSocketKey();
if (!$key) { appLog('[FOOD][SOCKET] INTERNAL_SOCKET_KEY missing — skip push', 'WARNING'); return; }
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query(['action' => $action, 'payload' => json_encode($payload)]),
CURLOPT_HTTPHEADER => ["X-Internal-Key: $key"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 2,
CURLOPT_CONNECTTIMEOUT => 1,
]);
$result = curl_exec($ch);
if ($result === false) {
appLog('[FOOD][SOCKET] push failed: ' . curl_error($ch), 'WARNING');
}
curl_close($ch);
}
function foodSendNotificationToPassenger(string $passengerId, string $title, string $body, array $data = []): void
{
global $redis;
if (!$passengerId) return;
$fcm = new FcmService($redis);
$result = $fcm->sendToTopic('passenger_' . $passengerId, $title, $body, $data);
if ($result['status'] !== 'success') {
appLog("[FOOD][FCM] push failed for passenger {$passengerId}: " . json_encode($result), 'WARNING');
}
}
// ── Session مطعم (هاتف+كلمة مرور — مستقلة عن JWT، مثل transit) ──
function foodCreateMerchantSession(int $merchantUserId, int $merchantId): string
{
global $redis;
$token = bin2hex(random_bytes(32));
$hash = hash('sha256', $token);
$expiresAt = date('Y-m-d H:i:s', time() + 86400);
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$con = Database::get('food');
$con->prepare(
"INSERT INTO food_merchant_sessions (merchant_user_id, merchant_id, token_hash, ip, user_agent, expires_at)
VALUES (?,?,?,?,?,?)"
)->execute([$merchantUserId, $merchantId, $hash, $ip, $ua, $expiresAt]);
if ($redis) {
$redis->setEx(
"food:merchant_session:{$hash}",
86400,
json_encode(['merchant_user_id' => $merchantUserId, 'merchant_id' => $merchantId])
);
}
return $token;
}
function foodAuthMerchant(): array
{
global $redis;
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['HTTP_X_FOOD_MERCHANT_TOKEN'] ?? '';
$token = str_replace('Bearer ', '', $header);
if (!$token) jsonError('Missing merchant session token', 401);
$hash = hash('sha256', $token);
if ($redis) {
$val = $redis->get("food:merchant_session:{$hash}");
if ($val) {
$data = json_decode($val, true);
if ($data) return $data;
}
jsonError('Session expired or invalid', 401);
}
$con = Database::get('food');
$st = $con->prepare(
"SELECT merchant_user_id, merchant_id FROM food_merchant_sessions
WHERE token_hash=? AND expires_at > NOW() LIMIT 1"
);
$st->execute([$hash]);
$row = $st->fetch();
if (!$row) jsonError('Session expired or invalid', 401);
return $row;
}
// ── هل المطعم مفتوح الآن؟ يحترم الإغلاق/الفتح اليدوي أولاً، ثم working_hours ──
function foodIsMerchantOpen(array $merchant): bool
{
if (isset($merchant['is_open_override']) && $merchant['is_open_override'] !== null) {
return (bool)$merchant['is_open_override'];
}
$hours = $merchant['working_hours'] ?? null;
if (!$hours) return true; // بلا جدول محدد = مفتوح افتراضياً
$hours = is_string($hours) ? json_decode($hours, true) : $hours;
if (!$hours) return true;
$dayKeys = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
$today = $dayKeys[(int)date('w')];
$ranges = $hours[$today] ?? [];
if (!$ranges) return false;
$now = date('H:i');
foreach ($ranges as $range) {
if (!isset($range[0], $range[1])) continue;
if ($now >= $range[0] && $now <= $range[1]) return true;
}
return false;
}
// ============================================================
// المحفظة — S2S عبر سيرفر المحفظة القُطري (نفس عقد backend/api/payments/initiate_prime.php)
//
// ⚠️ ملاحظة صريحة: سيرفر المحفظة الفعلي لا يعرض API حجز-ثم-التقاط (hold/capture)
// حقيقياً — العقد المتاح هو خصم/إضافة فوري فقط (action=subtract|add). لذلك
// "الحجز" هنا هو **خصم فوري عند الإنشاء + استرجاع كامل عند الرفض/الإلغاء**،
// وليس حجزاً بالمعنى المصرفي. إن أُضيف hold حقيقي لاحقاً في سيرفر المحفظة
// فهذه الدالة أول مكان يُعدَّل.
//
// ⚠️ ملاحظة ثانية: عامل تحويل "أصغر وحدة نقدية" (fils/qirsh) إلى المبلغ
// العشري الذي يتوقعه سيرفر المحفظة (كما في initiate_prime.php: 3.00 JOD)
// غير مؤكد لكل دولة — FOOD_CURRENCY_DIVISOR افتراضي 1000 (مثل JOD/fils).
// يجب تأكيده مع فريق المحفظة قبل أي تشغيل فعلي بمال حقيقي.
// ============================================================
function foodWalletServerUrl(): string
{
// bootstrap.php يعرّف الثابت GLOBAL_COUNTRY فقط (لا putenv) — استخدم الثابت لا getenv
$country = strtolower(defined('GLOBAL_COUNTRY') ? GLOBAL_COUNTRY : (getenv('GLOBAL_COUNTRY') ?: 'jordan'));
return match ($country) {
'egypt' => getenv('WALLET_SERVER_EGYPT') ?: 'https://wallet-egypt.siromove.com',
'syria' => getenv('WALLET_SERVER_SYRIA') ?: 'https://wallet-syria.siromove.com',
default => getenv('WALLET_SERVER_JORDAN') ?: 'https://walletintaleq.intaleq.xyz',
};
}
function foodSmallestUnitToDecimal(int $amount): float
{
$divisor = (float)(getenv('FOOD_CURRENCY_DIVISOR') ?: 1000);
return round($amount / $divisor, 3);
}
function foodWalletGetBalance(string $userId, string $userType = 'passenger'): ?float
{
$s2sKey = getenv('S2S_SHARED_KEY');
if (!$s2sKey) { appLog('[FOOD][WALLET] S2S_SHARED_KEY missing', 'ERROR'); return null; }
$url = foodWalletServerUrl() . '/v2/main/ride/passengerWallet/getWalletByPassenger.php';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query(['passenger_id' => $userId]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', "X-S2S-Api-Key: $s2sKey"],
]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (!$raw || $code !== 200) { appLog("[FOOD][WALLET] balance fetch failed HTTP $code", 'ERROR'); return null; }
$data = json_decode($raw, true);
$bal = $data['message'][0]['total'] ?? $data['total'] ?? null;
return $bal !== null ? (float)$bal : null;
}
// $amountSmallestUnit موجب دائماً؛ $action = subtract (خصم) أو add (إضافة/استرجاع)
function foodWalletMove(string $userId, int $amountSmallestUnit, string $action, string $paymentId, string $reason, string $userType = 'passenger'): bool
{
$s2sKey = getenv('S2S_SHARED_KEY');
if (!$s2sKey) { appLog('[FOOD][WALLET] S2S_SHARED_KEY missing', 'ERROR'); return false; }
$decimalAmount = foodSmallestUnitToDecimal($amountSmallestUnit);
$signedAmount = $action === 'subtract' ? -1 * $decimalAmount : $decimalAmount;
$url = foodWalletServerUrl() . '/v2/main/ride/payment/add.php';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'user_id' => $userId,
'user_type' => $userType,
'amount' => $signedAmount,
'action' => $action,
'paymentID' => $paymentId,
'paymentMethod' => 'food-order',
'reason' => $reason,
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', "X-S2S-Api-Key: $s2sKey"],
]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($err || $code !== 200) {
appLog("[FOOD][WALLET] move failed user=$userId action=$action HTTP $code err=$err", 'ERROR');
return false;
}
$res = json_decode($raw, true);
$ok = ($res['status'] ?? '') === 'success';
if (!$ok) appLog("[FOOD][WALLET] move rejected by wallet server: $raw", 'ERROR');
return $ok;
}
// ── توقيع عرض السعر (السلة) — يثبّت المجموع 10 دقائق، الخادم لا يثق بسعر العميل ──
function foodSignQuote(array $quote): string
{
$secret = getenv('SECRET_KEY_HMAC') ?: '';
$quote['expires_at'] = time() + 600;
$payload = base64_encode(json_encode($quote));
$sig = hash_hmac('sha256', $payload, $secret);
return $payload . '.' . $sig;
}
function foodVerifyQuote(string $token): array
{
$secret = getenv('SECRET_KEY_HMAC') ?: '';
$parts = explode('.', $token, 2);
if (count($parts) !== 2) jsonError('Invalid quote token', 400);
[$payload, $sig] = $parts;
$expected = hash_hmac('sha256', $payload, $secret);
if (!hash_equals($expected, $sig)) jsonError('Quote token signature mismatch', 400);
$quote = json_decode(base64_decode($payload), true);
if (!$quote) jsonError('Invalid quote token', 400);
if (($quote['expires_at'] ?? 0) < time()) jsonError('Quote expired — refresh your cart', 409);
return $quote;
}
// ============================================================
// أسطول التوصيل — نفس السائقين مع تمييز اختياري بالدور (can_deliver)
//
// ⚠️ لا نكتب على أي مفتاح Redis يديره loction_server/driver_socket.php —
// التعديل على تلك العملية الدائمة الحية خارج نطاق هذه الوحدة ومخاطرته
// عالية (تخدم كل مطابقة الرحلات). بدلاً من ذلك: سائق يفعّل "وضع التوصيل"
// من تطبيقه فيُضاف إلى SET مستقلة `food:couriers:opted_in`، ونتقاطع مع
// `geo:drivers:available` (يقرأها هذا الملف فقط — لا يكتب عليها أبداً)
// لإيجاد سائقين متاحين للرحلات فعلياً وأيضاً منضمّين لوضع التوصيل.
// ============================================================
function foodCourierOptIn(string $courierId, bool $enable): void
{
global $redis;
if (!$redis) return;
if ($enable) {
$redis->sAdd('food:couriers:opted_in', $courierId);
} else {
$redis->sRem('food:couriers:opted_in', $courierId);
}
}
function foodFindNearbyCouriers(float $lat, float $lng, float $radiusKm = 5, int $limit = 10): array
{
global $redisLocation, $redis;
if (!$redisLocation || !$redis) return [];
$nearby = $redisLocation->georadius(
'geo:drivers:available', $lng, $lat, $radiusKm, 'km', ['COUNT' => $limit, 'SORT' => 'ASC']
);
if (!$nearby) return [];
$optedIn = $redis->sMembers('food:couriers:opted_in');
if (!$optedIn) return [];
return array_values(array_intersect($nearby, $optedIn));
}
function foodOfferOrderToCourier(int $orderId, string $courierId): void
{
$con = Database::get('food');
$con->prepare(
"INSERT INTO food_courier_assignments (order_id, courier_id, status) VALUES (?,?,'offered')"
)->execute([$orderId, $courierId]);
// ملاحظة: لا FCM هنا عمداً — توكن جهاز السائق في جدول driverToken على main DB،
// وممنوع Database::get('main') داخل backend/food/ (نفس قاعدة transit). الإشعار
// اللحظي يمر فقط عبر socket_food (السائق متصل بسوكيته أثناء وضع التوصيل)،
// والتطبيق يعتمد أيضاً على courier/active.php كـ polling fallback عند الانقطاع.
foodPushToSocket('courier_offer', ['order_id' => $orderId, 'courier_id' => $courierId]);
}
// SET NX EX 20 — القابل الأول فقط يفوز، ذرّياً (يمنع سباق القبول)
function foodLockOrderForCourier(int $orderId, string $courierId): bool
{
global $redis;
if (!$redis) return false;
return (bool)$redis->set("food:order:{$orderId}:lock", $courierId, ['NX', 'EX' => 20]);
}
function foodOrderLockOwner(int $orderId): ?string
{
global $redis;
if (!$redis) return null;
$v = $redis->get("food:order:{$orderId}:lock");
return $v ?: null;
}
// ── حساب مبالغ الطلب من الخادم — العميل لا يُملي السعر أبداً ──
function foodComputeItemsTotal(array $lines): int
{
$total = 0;
foreach ($lines as $line) {
$total += (int)$line['line_total'];
}
return $total;
}
function foodComputeCommission(int $itemsTotal, float $commissionPercent): int
{
return (int) round($itemsTotal * $commissionPercent / 100);
}
+32
View File
@@ -0,0 +1,32 @@
<?php
// food/merchant/browse.php — تصفح المطاعم النشطة في مدينة الزبون
require_once __DIR__ . '/../connect_app.php';
$city = filterRequest('city');
if (!$city) jsonError('city is required');
$category = filterRequest('category');
$sql = "SELECT id, name_ar, name_en, logo_url, cover_url, city, category,
min_order_amount, avg_prep_minutes, rating_avg, rating_count,
is_open_override, working_hours
FROM food_merchants WHERE city=? AND status='active'";
$params = [$city];
if ($category) {
$sql .= " AND category=?";
$params[] = $category;
}
$sql .= " ORDER BY rating_avg DESC, rating_count DESC";
$st = $food_con->prepare($sql);
$st->execute($params);
$merchants = $st->fetchAll();
foreach ($merchants as &$m) {
$m['is_open'] = foodIsMerchantOpen($m);
unset($m['working_hours'], $m['is_open_override']);
}
unset($m);
jsonSuccess(['merchants' => $merchants]);
+59
View File
@@ -0,0 +1,59 @@
<?php
// food/merchant/details.php — تفاصيل مطعم واحد + قائمته الكاملة
require_once __DIR__ . '/../connect_app.php';
$merchantId = filterRequest('merchant_id', 'int');
if (!$merchantId) jsonError('merchant_id is required');
$st = $food_con->prepare(
"SELECT id, name_ar, name_en, logo_url, cover_url, description_ar, city, address,
latitude, longitude, category, min_order_amount, avg_prep_minutes,
rating_avg, rating_count, is_open_override, working_hours
FROM food_merchants WHERE id=? AND status='active' LIMIT 1"
);
$st->execute([$merchantId]);
$merchant = $st->fetch();
if (!$merchant) jsonError('Merchant not found', 404);
$merchant['is_open'] = foodIsMerchantOpen($merchant);
unset($merchant['working_hours'], $merchant['is_open_override']);
$catSt = $food_con->prepare(
"SELECT id, name_ar, name_en, sort_order FROM food_menu_categories
WHERE merchant_id=? AND is_active=1 ORDER BY sort_order ASC"
);
$catSt->execute([$merchantId]);
$categories = $catSt->fetchAll();
$itemSt = $food_con->prepare(
"SELECT id, category_id, name_ar, name_en, description_ar, image_url, price,
is_available, prep_minutes, sort_order
FROM food_menu_items WHERE merchant_id=? ORDER BY sort_order ASC"
);
$itemSt->execute([$merchantId]);
$items = $itemSt->fetchAll();
if ($items) {
$itemIds = implode(',', array_map('intval', array_column($items, 'id')));
$optSt = $food_con->query(
"SELECT id, item_id, group_name_ar, is_required, max_select, choices, sort_order
FROM food_item_options WHERE item_id IN ($itemIds) ORDER BY sort_order ASC"
);
$options = $optSt ? $optSt->fetchAll() : [];
$byItem = [];
foreach ($options as $o) {
$o['choices'] = json_decode($o['choices'], true);
$byItem[$o['item_id']][] = $o;
}
foreach ($items as &$it) {
$it['options'] = $byItem[$it['id']] ?? [];
}
unset($it);
}
foreach ($categories as &$c) {
$c['items'] = array_values(array_filter($items, fn($i) => (int)$i['category_id'] === (int)$c['id']));
}
unset($c);
jsonSuccess(['merchant' => $merchant, 'categories' => $categories]);
+24
View File
@@ -0,0 +1,24 @@
<?php
// food/merchant/search.php — بحث نصي في اسم المطعم أو أصنافه ضمن مدينة الزبون
require_once __DIR__ . '/../connect_app.php';
$city = filterRequest('city');
$q = filterRequest('q');
if (!$city) jsonError('city is required');
if (!$q || mb_strlen($q) < 2) jsonError('q must be at least 2 characters');
$like = '%' . $q . '%';
$st = $food_con->prepare(
"SELECT DISTINCT m.id, m.name_ar, m.name_en, m.logo_url, m.city, m.category,
m.rating_avg, m.rating_count
FROM food_merchants m
LEFT JOIN food_menu_items i ON i.merchant_id = m.id
WHERE m.city = ? AND m.status='active'
AND (m.name_ar LIKE ? OR m.name_en LIKE ? OR i.name_ar LIKE ? OR i.name_en LIKE ?)
ORDER BY m.rating_avg DESC
LIMIT 50"
);
$st->execute([$city, $like, $like, $like, $like]);
jsonSuccess(['merchants' => $st->fetchAll()]);
+41
View File
@@ -0,0 +1,41 @@
<?php
// food/merchant_auth/login.php — دخول لوحة المطعم (هاتف + كلمة مرور)
require_once __DIR__ . '/../../core/bootstrap.php';
require_once __DIR__ . '/../functions.php';
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
$limiter = new RateLimiter($redis);
$limiter->enforce(RateLimiter::identifier(), 'login');
requireFoodFields(['phone', 'password']);
$phone = normalizePhone(filterRequest('phone'));
$password = filterRequest('password');
try {
$food_con = Database::get('food');
} catch (Exception $e) {
jsonError('Food service unavailable', 503);
}
$st = $food_con->prepare(
"SELECT id, merchant_id, password_hash, is_active FROM food_merchant_users WHERE phone=? LIMIT 1"
);
$st->execute([$phone]);
$user = $st->fetch();
if (!$user || !$user['is_active'] || !password_verify($password, $user['password_hash'])) {
appLog("[FOOD][MERCHANT_LOGIN] failed attempt for phone hash " . hash('sha256', $phone), 'WARNING');
jsonError('Invalid credentials', 401);
}
$merchantSt = $food_con->prepare("SELECT status FROM food_merchants WHERE id=?");
$merchantSt->execute([$user['merchant_id']]);
$merchant = $merchantSt->fetch();
if (!$merchant || !in_array($merchant['status'], ['active', 'paused'], true)) {
jsonError('Merchant account is not active', 403);
}
$token = foodCreateMerchantSession((int)$user['id'], (int)$user['merchant_id']);
jsonSuccess(['session_token' => $token, 'merchant_id' => (int)$user['merchant_id']], 'Login successful');
+11
View File
@@ -0,0 +1,11 @@
<?php
// food/merchant_ops/accept.php — المطعم يقبل الطلب
require_once __DIR__ . '/../connect_merchant.php';
$orderId = filterRequest('order_id', 'int');
if (!$orderId) jsonError('order_id is required');
foodAssertOrderOwnership($orderId, 'merchant', (string)$food_merchant_id);
food_transition_status($orderId, 'merchant_accepted', 'merchant', (string)$food_merchant_user_id);
jsonSuccess(['order_id' => $orderId, 'status' => 'merchant_accepted']);
+39
View File
@@ -0,0 +1,39 @@
<?php
// food/merchant_ops/incoming.php — الطلبات الحالية للمطعم
require_once __DIR__ . '/../connect_merchant.php';
$statusFilter = filterRequest('status') ?: 'active';
$allowed = ['active', 'pending', 'merchant_accepted', 'preparing', 'ready', 'history'];
if (!in_array($statusFilter, $allowed, true)) $statusFilter = 'active';
if ($statusFilter === 'active') {
$sql = "SELECT id, passenger_id, status, items_total, delivery_fee, grand_total, created_at
FROM food_orders WHERE merchant_id=? AND status IN ('pending','merchant_accepted','preparing','ready','courier_assigned','picked_up')
ORDER BY created_at ASC";
$params = [$food_merchant_id];
} elseif ($statusFilter === 'history') {
$sql = "SELECT id, passenger_id, status, items_total, delivery_fee, grand_total, created_at, delivered_at
FROM food_orders WHERE merchant_id=? AND status IN ('delivered','rejected','cancelled_by_customer','cancelled_by_merchant','cancelled_system')
ORDER BY created_at DESC LIMIT 100";
$params = [$food_merchant_id];
} else {
$sql = "SELECT id, passenger_id, status, items_total, delivery_fee, grand_total, created_at
FROM food_orders WHERE merchant_id=? AND status=? ORDER BY created_at ASC";
$params = [$food_merchant_id, $statusFilter];
}
$st = $food_con->prepare($sql);
$st->execute($params);
$orders = $st->fetchAll();
if ($orders) {
$ids = implode(',', array_map('intval', array_column($orders, 'id')));
$itemsSt = $food_con->query("SELECT order_id, name_ar_snapshot, quantity FROM food_order_items WHERE order_id IN ($ids)");
$allItems = $itemsSt ? $itemsSt->fetchAll() : [];
foreach ($orders as &$o) {
$o['items'] = array_values(array_filter($allItems, fn($i) => (int)$i['order_id'] === (int)$o['id']));
}
unset($o);
}
jsonSuccess(['orders' => $orders]);
@@ -0,0 +1,17 @@
<?php
// food/merchant_ops/items_toggle.php — تفعيل/إيقاف صنف (نفد من المخزون مثلاً)
require_once __DIR__ . '/../connect_merchant.php';
$itemId = filterRequest('item_id', 'int');
if (!$itemId) jsonError('item_id is required');
$st = $food_con->prepare("SELECT id, merchant_id, is_available FROM food_menu_items WHERE id=? LIMIT 1");
$st->execute([$itemId]);
$item = $st->fetch();
if (!$item) jsonError('Item not found', 404);
if ((int)$item['merchant_id'] !== $food_merchant_id) jsonError('Forbidden', 403);
$newAvailability = $item['is_available'] ? 0 : 1;
$food_con->prepare("UPDATE food_menu_items SET is_available=? WHERE id=?")->execute([$newAvailability, $itemId]);
jsonSuccess(['item_id' => $itemId, 'is_available' => (bool)$newAvailability]);
+11
View File
@@ -0,0 +1,11 @@
<?php
// food/merchant_ops/preparing.php — المطعم بدأ التحضير
require_once __DIR__ . '/../connect_merchant.php';
$orderId = filterRequest('order_id', 'int');
if (!$orderId) jsonError('order_id is required');
foodAssertOrderOwnership($orderId, 'merchant', (string)$food_merchant_id);
food_transition_status($orderId, 'preparing', 'merchant', (string)$food_merchant_user_id);
jsonSuccess(['order_id' => $orderId, 'status' => 'preparing']);
+36
View File
@@ -0,0 +1,36 @@
<?php
// food/merchant_ops/ready.php — الطلب جاهز — يبدأ حلقة إسناد السائق
require_once __DIR__ . '/../connect_merchant.php';
$orderId = filterRequest('order_id', 'int');
if (!$orderId) jsonError('order_id is required');
foodAssertOrderOwnership($orderId, 'merchant', (string)$food_merchant_id);
food_transition_status($orderId, 'ready', 'merchant', (string)$food_merchant_user_id);
$merchantSt = $food_con->prepare("SELECT latitude, longitude FROM food_merchants WHERE id=?");
$merchantSt->execute([$food_merchant_id]);
$merchant = $merchantSt->fetch();
$offered = false;
if ($merchant) {
$candidates = foodFindNearbyCouriers((float)$merchant['latitude'], (float)$merchant['longitude']);
// استبعاد من سبق عرض هذا الطلب عليه (رفض أو انتهت مهلته)
$priorSt = $food_con->prepare("SELECT courier_id FROM food_courier_assignments WHERE order_id=?");
$priorSt->execute([$orderId]);
$prior = array_column($priorSt->fetchAll(), 'courier_id');
foreach ($candidates as $courierId) {
if (in_array($courierId, $prior, true)) continue;
foodOfferOrderToCourier($orderId, $courierId);
$offered = true;
break;
}
}
if (!$offered) {
appLog("[FOOD][DELIVERY] No available courier found for order $orderId at ready-time", 'WARNING');
}
jsonSuccess(['order_id' => $orderId, 'status' => 'ready', 'courier_offer_sent' => $offered]);
+24
View File
@@ -0,0 +1,24 @@
<?php
// food/merchant_ops/reject.php — المطعم يرفض الطلب — استرجاع فوري إن دُفع من المحفظة
require_once __DIR__ . '/../connect_merchant.php';
$orderId = filterRequest('order_id', 'int');
$reason = filterRequest('reason');
if (!$orderId) jsonError('order_id is required');
$order = foodAssertOrderOwnership($orderId, 'merchant', (string)$food_merchant_id);
food_transition_status($orderId, 'rejected', 'merchant', (string)$food_merchant_user_id, $reason);
if ($order['payment_method'] === 'wallet') {
$refunded = foodWalletMove(
(string)$order['passenger_id'], (int)$order['grand_total'], 'add',
"food-refund-{$orderId}", "Food order #{$orderId} rejected by merchant"
);
$food_con->prepare(
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'release',?,?)"
)->execute([$orderId, (int)$order['grand_total'], $refunded ? 'success' : 'failed']);
if (!$refunded) appLog("[FOOD][MERCHANT][reject] refund FAILED for order $orderId — needs manual reconciliation", 'ERROR');
}
jsonSuccess(['order_id' => $orderId, 'status' => 'rejected']);
+28
View File
@@ -0,0 +1,28 @@
<?php
// food/order/cancel.php — إلغاء الزبون قبل قبول المطعم فقط (بعده يتطلب اتصالاً بالمطعم)
require_once __DIR__ . '/../connect_app.php';
$orderId = filterRequest('order_id', 'int');
$reason = filterRequest('reason');
if (!$orderId) jsonError('order_id is required');
$order = foodAssertOrderOwnership($orderId, 'customer', $food_passenger_id);
if ($order['status'] !== 'pending') {
jsonError('Order can no longer be cancelled directly — it has already been accepted by the merchant', 409);
}
food_transition_status($orderId, 'cancelled_by_customer', 'customer', $food_passenger_id, $reason);
if ($order['payment_method'] === 'wallet') {
$refunded = foodWalletMove(
$food_passenger_id, (int)$order['grand_total'], 'add',
"food-refund-{$orderId}", "Food order #{$orderId} cancelled"
);
$food_con->prepare(
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'release',?,?)"
)->execute([$orderId, (int)$order['grand_total'], $refunded ? 'success' : 'failed']);
if (!$refunded) appLog("[FOOD][ORDER][cancel] refund FAILED for order $orderId — needs manual reconciliation", 'ERROR');
}
jsonSuccess(['order_id' => $orderId, 'status' => 'cancelled_by_customer']);
+113
View File
@@ -0,0 +1,113 @@
<?php
// food/order/create.php — إنشاء الطلب من عرض سعر موقّع + خصم فوري من المحفظة
// body: quote_token, client_order_uuid, delivery_address, delivery_lat, delivery_lng,
// payment_method (wallet|cash), customer_note
require_once __DIR__ . '/../connect_app.php';
requireFoodFields(['quote_token', 'client_order_uuid', 'delivery_address', 'delivery_lat', 'delivery_lng']);
$quoteToken = filterRequest('quote_token');
$clientUuid = filterRequest('client_order_uuid');
$address = filterRequest('delivery_address');
$lat = filterRequest('delivery_lat', 'float');
$lng = filterRequest('delivery_lng', 'float');
$paymentMethod = filterRequest('payment_method') ?: 'wallet';
$note = filterRequest('customer_note');
if (!in_array($paymentMethod, ['wallet', 'cash'], true)) jsonError('Invalid payment_method');
if (!preg_match('/^[0-9a-fA-F-]{36}$/', $clientUuid)) jsonError('client_order_uuid must be a valid UUID');
$quote = foodVerifyQuote($quoteToken);
if ((string)$quote['passenger_id'] !== $food_passenger_id) jsonError('Quote does not belong to this session', 403);
// idempotency — الضغط المزدوج أو إعادة محاولة الشبكة يرجع نفس الطلب لا طلباً جديداً
$dupSt = $food_con->prepare("SELECT id FROM food_orders WHERE client_order_uuid=? LIMIT 1");
$dupSt->execute([$clientUuid]);
if ($existing = $dupSt->fetch()) {
jsonSuccess(['order_id' => (int)$existing['id'], 'idempotent_replay' => true], 'Order already exists');
}
$maxActive = (int)(getenv('FOOD_MAX_ACTIVE_ORDERS_PER_USER') ?: 3);
$activeSt = $food_con->prepare(
"SELECT COUNT(*) c FROM food_orders WHERE passenger_id=? AND status NOT IN
('delivered','rejected','cancelled_by_customer','cancelled_by_merchant','cancelled_system')"
);
$activeSt->execute([$food_passenger_id]);
if ((int)$activeSt->fetch()['c'] >= $maxActive) {
jsonError("You already have $maxActive active orders — finish or cancel one first", 409);
}
$merchantSt = $food_con->prepare("SELECT id, status, commission_percent FROM food_merchants WHERE id=? LIMIT 1");
$merchantSt->execute([$quote['merchant_id']]);
$merchant = $merchantSt->fetch();
if (!$merchant || $merchant['status'] !== 'active') jsonError('Merchant is no longer available', 409);
$itemsTotal = (int)$quote['items_total'];
$deliveryFee = (int)$quote['delivery_fee'];
$serviceFee = (int)$quote['service_fee'];
$grandTotal = (int)$quote['grand_total'];
$commission = foodComputeCommission($itemsTotal, (float)$merchant['commission_percent']);
if ($paymentMethod === 'wallet') {
$balance = foodWalletGetBalance($food_passenger_id);
if ($balance === null) jsonError('Unable to verify wallet balance. Please try again.', 503);
if ($balance < foodSmallestUnitToDecimal($grandTotal)) {
jsonError('Insufficient wallet balance', 402, ['current_balance' => $balance]);
}
}
$food_con->beginTransaction();
try {
$food_con->prepare(
"INSERT INTO food_orders
(client_order_uuid, passenger_id, merchant_id, status, items_total, delivery_fee, service_fee,
discount, grand_total, commission_amount, payment_method, delivery_address, delivery_lat,
delivery_lng, customer_note)
VALUES (?,?,?,'pending',?,?,?,0,?,?,?,?,?,?,?)"
)->execute([
$clientUuid, $food_passenger_id, $quote['merchant_id'], $itemsTotal, $deliveryFee, $serviceFee,
$grandTotal, $commission, $paymentMethod, $address, $lat, $lng, $note,
]);
$orderId = (int)$food_con->lastInsertId();
$insertLine = $food_con->prepare(
"INSERT INTO food_order_items (order_id, item_id, name_ar_snapshot, unit_price, quantity, option_price_json, line_total)
VALUES (?,?,?,?,?,?,?)"
);
foreach ($quote['lines'] as $line) {
$insertLine->execute([
$orderId, $line['item_id'], $line['name_ar_snapshot'], $line['unit_price'],
$line['quantity'], json_encode($line['options']), $line['line_total'],
]);
}
$food_con->prepare(
"INSERT INTO food_order_status_log (order_id, from_status, to_status, actor_type, actor_id)
VALUES (?,NULL,'pending','customer',?)"
)->execute([$orderId, $food_passenger_id]);
$food_con->commit();
} catch (Throwable $e) {
$food_con->rollBack();
appLog('[FOOD][ORDER][create] ' . $e->getMessage(), 'ERROR');
jsonError('Failed to create order', 500);
}
if ($paymentMethod === 'wallet') {
$debited = foodWalletMove($food_passenger_id, $grandTotal, 'subtract', "food-order-{$orderId}", "Food order #{$orderId}");
if (!$debited) {
// فشل الخصم بعد إنشاء السجل — نُلغي الطلب فوراً بدل ترك طلب بلا دفع
food_transition_status($orderId, 'cancelled_system', 'system', 'wallet', 'Wallet debit failed');
jsonError('Wallet payment failed — order cancelled', 402);
}
$food_con->prepare(
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'hold',?,'success')"
)->execute([$orderId, $grandTotal]);
}
foodPushToSocket('order_status_update', [
'order_id' => $orderId, 'status' => 'pending', 'passenger_id' => $food_passenger_id,
'merchant_id' => (int)$quote['merchant_id'], 'courier_id' => null,
]);
jsonSuccess(['order_id' => $orderId, 'status' => 'pending', 'grand_total' => $grandTotal], 'Order placed');
+20
View File
@@ -0,0 +1,20 @@
<?php
// food/order/history.php — سجل طلبات الزبون
require_once __DIR__ . '/../connect_app.php';
$page = max(1, (int)(filterRequest('page', 'int') ?? 1));
$limit = 20;
$offset = ($page - 1) * $limit;
$st = $food_con->prepare(
"SELECT o.id, o.status, o.grand_total, o.created_at, o.delivered_at, o.rating,
m.name_ar AS merchant_name_ar, m.logo_url AS merchant_logo_url
FROM food_orders o
JOIN food_merchants m ON m.id = o.merchant_id
WHERE o.passenger_id = ?
ORDER BY o.created_at DESC
LIMIT $limit OFFSET $offset"
);
$st->execute([$food_passenger_id]);
jsonSuccess(['orders' => $st->fetchAll(), 'page' => $page]);
+34
View File
@@ -0,0 +1,34 @@
<?php
// food/order/rate.php — تقييم الطلب بعد التسليم (يحدّث متوسط تقييم المطعم)
require_once __DIR__ . '/../connect_app.php';
$orderId = filterRequest('order_id', 'int');
$rating = filterRequest('rating', 'int');
$comment = filterRequest('comment');
if (!$orderId || !$rating || $rating < 1 || $rating > 5) jsonError('order_id and rating (1-5) are required');
$order = foodAssertOrderOwnership($orderId, 'customer', $food_passenger_id);
if ($order['status'] !== 'delivered') jsonError('Only delivered orders can be rated', 409);
if ($order['rating'] !== null) jsonError('Order already rated', 409);
$food_con->beginTransaction();
try {
$food_con->prepare("UPDATE food_orders SET rating=?, rating_comment=? WHERE id=?")
->execute([$rating, $comment, $orderId]);
$food_con->prepare(
"UPDATE food_merchants SET
rating_avg = ((rating_avg * rating_count) + ?) / (rating_count + 1),
rating_count = rating_count + 1
WHERE id=?"
)->execute([$rating, $order['merchant_id']]);
$food_con->commit();
} catch (Throwable $e) {
$food_con->rollBack();
appLog('[FOOD][ORDER][rate] ' . $e->getMessage(), 'ERROR');
jsonError('Failed to save rating', 500);
}
jsonSuccess(['order_id' => $orderId, 'rating' => $rating]);
+15
View File
@@ -0,0 +1,15 @@
<?php
// food/order/status.php — حالة الطلب الحالية (مصدر الحقيقة عند إعادة الاتصال بالسوكيت)
require_once __DIR__ . '/../connect_app.php';
$orderId = filterRequest('order_id', 'int');
if (!$orderId) jsonError('order_id is required');
$order = foodAssertOrderOwnership($orderId, 'customer', $food_passenger_id);
$itemsSt = $food_con->prepare("SELECT name_ar_snapshot, unit_price, quantity, line_total FROM food_order_items WHERE order_id=?");
$itemsSt->execute([$orderId]);
$order['items'] = $itemsSt->fetchAll();
// عناوين/أرقام الزبون تُحجب عن السائق قبل courier_assigned وبعد delivered — هنا هي بوابة الزبون نفسه فلا حجب
jsonSuccess(['order' => $order]);
+6
View File
@@ -0,0 +1,6 @@
<?php
// food/ping.php — فحص الأساس: الحاوية php_food + قاعدة siro_food تعملان
require_once __DIR__ . '/connect_app.php';
$food_con->query('SELECT 1');
jsonSuccess(['service' => 'food', 'db' => 'ok'], 'pong');
+289
View File
@@ -0,0 +1,289 @@
-- =============================================================
-- schema_food.sql — قاعدة بيانات وحدة طلبات الطعام (siro_food)
-- عزل كامل عن main/ride/transit — ممنوع أي JOIN خارجي
-- الربط بالنظام الرئيسي عبر passenger_id / courier_id (main driver id) فقط
-- =============================================================
SET FOREIGN_KEY_CHECKS = 0;
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
SET time_zone = "+00:00";
-- -----------------------------------------------------------------
-- 1. food_merchants — المطاعم/المتاجر
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_merchants` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name_ar` VARCHAR(150) NOT NULL,
`name_en` VARCHAR(150) DEFAULT NULL,
`logo_url` VARCHAR(500) DEFAULT NULL,
`cover_url` VARCHAR(500) DEFAULT NULL,
`description_ar` VARCHAR(500) DEFAULT NULL,
`city` VARCHAR(80) NOT NULL,
`address` VARCHAR(300) DEFAULT NULL,
`latitude` DECIMAL(10,7) NOT NULL,
`longitude` DECIMAL(10,7) NOT NULL,
`category` VARCHAR(60) DEFAULT NULL COMMENT 'مطبخ عربي، بيتزا، حلويات...',
`commission_percent` DECIMAL(5,2) NOT NULL DEFAULT 15.00,
`min_order_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'أصغر وحدة نقدية',
`avg_prep_minutes` SMALLINT UNSIGNED NOT NULL DEFAULT 20,
`rating_avg` DECIMAL(3,2) NOT NULL DEFAULT 0.00,
`rating_count` INT UNSIGNED NOT NULL DEFAULT 0,
`status` ENUM('pending_approval','active','paused','suspended','rejected') NOT NULL DEFAULT 'pending_approval',
`is_open_override` TINYINT(1) DEFAULT NULL COMMENT 'NULL=يتبع working_hours، 0/1=إغلاق/فتح يدوي فوري',
`working_hours` JSON DEFAULT NULL COMMENT '{"sun":[["09:00","23:00"]], ...}',
`approved_by` INT UNSIGNED DEFAULT NULL COMMENT 'admin id من النظام الرئيسي',
`approved_at` TIMESTAMP DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_city_status` (`city`, `status`),
KEY `idx_location` (`latitude`, `longitude`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 2. food_merchant_users — حسابات دخول أصحاب المطاعم (دور merchant، هوية منفصلة عن الراكب)
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_merchant_users` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`merchant_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(120) NOT NULL,
`phone` VARCHAR(25) NOT NULL COMMENT 'مشفّر بنفس EncryptionHelper',
`role` ENUM('owner','staff') NOT NULL DEFAULT 'owner',
`password_hash` VARCHAR(255) NOT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_phone` (`phone`),
KEY `idx_merchant` (`merchant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 3. food_menu_categories — أقسام قائمة المطعم
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_menu_categories` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`merchant_id` INT UNSIGNED NOT NULL,
`name_ar` VARCHAR(100) NOT NULL,
`name_en` VARCHAR(100) DEFAULT NULL,
`sort_order` SMALLINT NOT NULL DEFAULT 0,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_merchant_sort` (`merchant_id`, `sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 4. food_menu_items — أصناف القائمة
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_menu_items` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`merchant_id` INT UNSIGNED NOT NULL,
`category_id` INT UNSIGNED NOT NULL,
`name_ar` VARCHAR(150) NOT NULL,
`name_en` VARCHAR(150) DEFAULT NULL,
`description_ar` VARCHAR(500) DEFAULT NULL,
`image_url` VARCHAR(500) DEFAULT NULL,
`price` BIGINT UNSIGNED NOT NULL COMMENT 'أصغر وحدة نقدية',
`is_available` TINYINT(1) NOT NULL DEFAULT 1,
`prep_minutes` SMALLINT UNSIGNED DEFAULT NULL COMMENT 'NULL = يستخدم avg_prep_minutes للمطعم',
`sort_order` SMALLINT NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_merchant_available` (`merchant_id`, `is_available`),
KEY `idx_category` (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 5. food_item_options — مجموعات خيارات الصنف (حجم، إضافات...)
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_item_options` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`item_id` INT UNSIGNED NOT NULL,
`group_name_ar` VARCHAR(100) NOT NULL COMMENT 'مثال: الحجم، الإضافات',
`is_required` TINYINT(1) NOT NULL DEFAULT 0,
`max_select` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=اختيار واحد، أكثر=متعدد',
`choices` JSON NOT NULL COMMENT '[{"id":"lg","label_ar":"كبير","price":500}]',
`sort_order` SMALLINT NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_item` (`item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 6. food_orders — الطلب
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_orders` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`client_order_uuid` CHAR(36) NOT NULL COMMENT 'idempotency — يمنع الضغط المزدوج من إنشاء طلبين',
`passenger_id` VARCHAR(100) NOT NULL COMMENT 'مرجع منطقي فقط — لا FK خارجي',
`merchant_id` INT UNSIGNED NOT NULL,
`courier_id` VARCHAR(100) DEFAULT NULL COMMENT 'main driver id بعد الإسناد',
`status` ENUM(
'pending','merchant_accepted','preparing','ready',
'courier_assigned','picked_up','delivered',
'rejected','cancelled_by_customer','cancelled_by_merchant','cancelled_system'
) NOT NULL DEFAULT 'pending',
`items_total` BIGINT UNSIGNED NOT NULL COMMENT 'مجموع أصناف الطلب — أصغر وحدة نقدية',
`delivery_fee` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`service_fee` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`discount` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`grand_total` BIGINT UNSIGNED NOT NULL COMMENT 'items_total+delivery_fee+service_fee-discount',
`commission_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'نصيب المنصة من items_total',
`payment_method` ENUM('wallet','cash') NOT NULL DEFAULT 'wallet',
`payment_hold_ref` VARCHAR(100) DEFAULT NULL COMMENT 'مرجع حجز المحفظة في payment_server/v2',
`delivery_address` VARCHAR(300) NOT NULL,
`delivery_lat` DECIMAL(10,7) NOT NULL,
`delivery_lng` DECIMAL(10,7) NOT NULL,
`customer_note` VARCHAR(300) DEFAULT NULL,
`promo_code` VARCHAR(40) DEFAULT NULL,
`rating` TINYINT UNSIGNED DEFAULT NULL,
`rating_comment` VARCHAR(300) DEFAULT NULL,
`merchant_accepted_at` TIMESTAMP DEFAULT NULL,
`ready_at` TIMESTAMP DEFAULT NULL,
`courier_assigned_at` TIMESTAMP DEFAULT NULL,
`picked_up_at` TIMESTAMP DEFAULT NULL,
`delivered_at` TIMESTAMP DEFAULT NULL,
`cancelled_at` TIMESTAMP DEFAULT NULL,
`cancel_reason` VARCHAR(300) DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_client_uuid` (`client_order_uuid`),
KEY `idx_passenger_created` (`passenger_id`, `created_at`),
KEY `idx_merchant_status` (`merchant_id`, `status`),
KEY `idx_courier_status` (`courier_id`, `status`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 7. food_order_items — أصناف الطلب بسعر مجمّد وقت الطلب
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_order_items` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`order_id` INT UNSIGNED NOT NULL,
`item_id` INT UNSIGNED NOT NULL COMMENT 'مرجع فقط — لا JOIN للسعر',
`name_ar_snapshot` VARCHAR(150) NOT NULL,
`unit_price` BIGINT UNSIGNED NOT NULL COMMENT 'سعر الوحدة وقت الطلب — مجمّد',
`quantity` SMALLINT UNSIGNED NOT NULL DEFAULT 1,
`option_price_json` JSON DEFAULT NULL COMMENT 'الخيارات المختارة وأسعارها وقت الطلب',
`line_total` BIGINT UNSIGNED NOT NULL COMMENT '(unit_price+مجموع الخيارات)×quantity',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 8. food_order_status_log — كل انتقال حالة — مصدر الحقيقة للنزاعات
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_order_status_log` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`order_id` INT UNSIGNED NOT NULL,
`from_status` VARCHAR(30) DEFAULT NULL,
`to_status` VARCHAR(30) NOT NULL,
`actor_type` ENUM('customer','merchant','courier','admin','system') NOT NULL,
`actor_id` VARCHAR(100) DEFAULT NULL,
`note` VARCHAR(300) DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 9. food_order_payments — مرجع معاملة المحفظة/الدفع + التسوية
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_order_payments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`order_id` INT UNSIGNED NOT NULL,
`type` ENUM('hold','capture','release','cash_settlement','courier_payout') NOT NULL,
`amount` BIGINT UNSIGNED NOT NULL,
`wallet_ref` VARCHAR(100) DEFAULT NULL COMMENT 'مرجع من payment_server/v2',
`status` ENUM('pending','success','failed') NOT NULL DEFAULT 'pending',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 10. food_courier_assignments — محاولات إسناد الطلب لسائق
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_courier_assignments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`order_id` INT UNSIGNED NOT NULL,
`courier_id` VARCHAR(100) NOT NULL COMMENT 'main driver id',
`status` ENUM('offered','accepted','rejected','timed_out') NOT NULL DEFAULT 'offered',
`offered_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`responded_at` TIMESTAMP DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_order` (`order_id`),
KEY `idx_courier` (`courier_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 11. food_merchant_payouts — مستحقات المطاعم ودورات التسوية
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_merchant_payouts` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`merchant_id` INT UNSIGNED NOT NULL,
`period_start` DATE NOT NULL,
`period_end` DATE NOT NULL,
`orders_count` INT UNSIGNED NOT NULL DEFAULT 0,
`gross_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`commission_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`net_payout` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`status` ENUM('pending','paid') NOT NULL DEFAULT 'pending',
`paid_at` TIMESTAMP DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_merchant_period` (`merchant_id`, `period_start`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 12. food_promo_codes — أكواد خصم خاصة بالطعام (منفصلة عن أكواد الرحلات)
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_promo_codes` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`code` VARCHAR(40) NOT NULL,
`discount_type` ENUM('percent','fixed') NOT NULL DEFAULT 'fixed',
`discount_value` INT UNSIGNED NOT NULL,
`max_discount` BIGINT UNSIGNED DEFAULT NULL,
`min_order_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`usage_limit` INT UNSIGNED DEFAULT NULL,
`usage_count` INT UNSIGNED NOT NULL DEFAULT 0,
`valid_from` TIMESTAMP DEFAULT NULL,
`valid_until` TIMESTAMP DEFAULT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 13. food_merchant_sessions — جلسات دخول لوحة المطعم (هاتف+كلمة مرور،
-- session token مستقل عن JWT — نفس نمط transit_sessions، لأن لوحة
-- المطعم ويب متجاوب لا يملك device fingerprint كتطبيق الجوال)
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_merchant_sessions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`merchant_user_id` INT UNSIGNED NOT NULL,
`merchant_id` INT UNSIGNED NOT NULL,
`token_hash` VARCHAR(64) NOT NULL COMMENT 'sha256 للـ session token',
`ip` VARCHAR(45) DEFAULT NULL,
`user_agent` VARCHAR(300) DEFAULT NULL,
`expires_at` TIMESTAMP NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_token` (`token_hash`),
KEY `idx_merchant_user` (`merchant_user_id`),
KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET FOREIGN_KEY_CHECKS = 1;
-- -----------------------------------------------------------------
-- ملاحظات Redis (مفاتيح تُكتب من PHP — ليست في SQL):
-- food:order:{id}:lock → SET NX EX 20 عند قبول سائق (يمنع سباق القبول)
-- geo:couriers:available → مجموعة جغرافية للسائقين can_deliver=1 المتفرغين
-- food:merchant:{id}:cache → كاش بيانات مطعم للقراءة السريعة (اختياري لاحقاً)
-- Redis channels للسوكيت: food:order:{id} / food:merchant:{id} / food:courier:{id}
-- -----------------------------------------------------------------
+11 -5
View File
@@ -59,7 +59,7 @@ function isAllowedSocketUrl(string $url): bool {
}
function sendToLocationServer($action, $data) {
$url = getenv('LOCATION_SERVER_URL') ?: 'http://nginx/loction_server/driver_socket.php';
$url = getenv('LOCATION_SERVER_URL') ?: 'http://socket_driver:2021';
if (!isAllowedSocketUrl($url)) {
error_log("[SSRF_BLOCKED] Attempted connection to: $url");
return;
@@ -258,7 +258,7 @@ function getDistanceBetweenPoints($lat1, $lon1, $lat2, $lon2) {
}
// --- دالة مساعدة لمخاطبة سيرفر السائقين (Location Socket) ---
function notifyDriversRideTaken($rideId, $winnerDriverId) {
$url = getenv('LOCATION_SERVER_URL') ?: 'http://nginx/loction_server/driver_socket.php';
$url = getenv('LOCATION_SERVER_URL') ?: 'http://socket_driver:2021';
if (!isAllowedSocketUrl($url)) return;
$INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : '';
@@ -280,7 +280,7 @@ function notifyDriversRideTaken($rideId, $winnerDriverId) {
curl_close($ch);
}
function notifyDriversOnLocationServer($drivers_ids_array, $payload, $rideId = null) {
$url = getenv('LOCATION_SERVER_URL') ?: 'http://nginx/loction_server/driver_socket.php';
$url = getenv('LOCATION_SERVER_URL') ?: 'http://socket_driver:2021';
if (!isAllowedSocketUrl($url)) return null;
$INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : '';
@@ -317,7 +317,13 @@ function notifyDriversOnLocationServer($drivers_ids_array, $payload, $rideId = n
*/
function notifyPassengerOnRideServer($passenger_id, $payload) {
$url = getenv('PASSENGER_SOCKET_INTERNAL_URL') ?: (getenv('RIDE_SOCKET_URL') ?: 'http://socket_passenger:3031');
if (!isAllowedSocketUrl($url)) return null;
if (!isAllowedSocketUrl($url)) {
// كان يرجع null بصمت تام — عكس sendToLocationServer — فإذا كان
// ALLOWED_SOCKET_URLS لا يغطّي عنوان السوكيت تسقط كل أحداث الراكب
// بلا أي أثر في اللوج.
error_log("[SOCKET_BLOCKED] Passenger socket URL not in ALLOWED_SOCKET_URLS: $url");
return null;
}
$INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : '';
if (empty($INTERNAL_KEY)) {
@@ -363,7 +369,7 @@ function dispatchRideToDrivers($driversData, $rideId, $payloadTemplate, $startNa
$countDrivers = count($driversData);
error_log("🚀 [DISPATCH_START] RideID: $rideId | Drivers Count: $countDrivers");
$socketUrl = getenv('LOCATION_SERVER_URL') ?: 'http://nginx/loction_server/driver_socket.php';
$socketUrl = getenv('LOCATION_SERVER_URL') ?: 'http://socket_driver:2021';
if (!isAllowedSocketUrl($socketUrl)) return;
$internalKey = function_exists('getInternalSocketKey') ? getInternalSocketKey() : '';
+53 -14
View File
@@ -6,17 +6,33 @@
require_once __DIR__ . '/core/bootstrap.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: https://siromove.com');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Device-FP');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
$startTime = microtime(true);
/**
* هل البصمة المخزّنة بالصيغة القديمة (AES-GCM) التي يستحيل مطابقتها؟
*
* • '' ⇒ مستخدم بلا بصمة مسجّلة بعد → يُسمح بالترحيل
* • 'GCM:…' ⇒ ناتج AES-GCM خام (addToken/verify_otp) → يُسمح بالترحيل
* • 64 محرف hex ⇒ hash لناتج GCM كتبه الترحيل القديم → يُسمح بالترحيل
* • غير ذلك ⇒ AES-CBC حتمي (الصيغة الجديدة) → مقارنة صارمة
*/
function isLegacyGcmFingerprint(string $storedFp): bool
{
if ($storedFp === '') {
return true;
}
if (str_starts_with($storedFp, 'GCM:')) {
return true;
}
return (bool)preg_match('/^[0-9a-f]{64}$/i', $storedFp);
}
try {
$limiter = new RateLimiter($redis);
$limiter->enforce(RateLimiter::identifier(), 'login');
@@ -29,6 +45,20 @@ try {
jsonError('Missing required parameters', 400);
}
// ── التحقق من الـ audience (نفس منطق loginFirstTime.php) ────────
// يمنع استخراج توكن بـ audience المحفظة من مسار الراكب العادي.
$allowed1 = getenv('allowed1');
$allowed2 = getenv('allowed2');
$allowedAudiences = array_values(array_filter([$allowed1, $allowed2]));
if (!in_array($audience, $allowedAudiences, true)) {
securityLog("Login rejected: invalid audience", [
'passengerId' => $passengerId,
'audience' => $audience,
]);
jsonError('Invalid audience', 400);
}
$con = Database::get('main');
// التحقق من الجهاز من خلال البصمة
@@ -42,11 +72,10 @@ try {
$row = $stmt->fetch();
$fpVerified = false;
$fpJustSaved = false;
if ($row) {
$fpPepper = getenv('FP_PEPPER') ?: '';
$storedFp = $row['fingerPrint'] ?? $row['fingerprint'] ?? '';
// دعم الطريقة الجديدة (hash) والقديمة (مباشر)
if ($fpPepper) {
$expectedHash = hash('sha256', $fingerprint . $fpPepper);
@@ -58,13 +87,23 @@ try {
$fpVerified = hash_equals($storedFp, $fingerprint);
}
// بصمة GCM تتغير في كل مرة (random IV) لذا نقبل أي بصمة جديدة ونحدثها
if (!$fpVerified && !empty($fingerprint)) {
$fpPepper = getenv('FP_PEPPER') ?: '';
$newHash = $fpPepper ? hash('sha256', $fingerprint . $fpPepper) : $fingerprint;
// ── ترحيل لمرة واحدة: بصمات AES-GCM القديمة ────────────────
// النسخ القديمة من تطبيق الراكب كانت تشفّر البصمة بـ AES-GCM
// بـ IV عشوائي، فالناتج يختلف في كل مرة ولا يمكن مطابقته إطلاقاً.
// النسخة الجديدة تستخدم AES-CBC بـ IV ثابت (ناتج حتمي).
// لذلك نقبل استبدال البصمة مرة واحدة فقط عندما تكون المخزّنة
// بصيغة GCM القديمة؛ وأي بصمة بالصيغة الجديدة تُقارن بصرامة
// ويتكفّل مسار الـ OTP بتغيير الجهاز.
// ⚠️ يُحذف هذا الفرع بعد اكتمال ترحيل المستخدمين.
if (!$fpVerified && !empty($fingerprint) && isLegacyGcmFingerprint($storedFp)) {
// نخزّن القيمة الخام (كما يفعل addToken.php و verify_otp.php)
// كي تبقى الصيغة قابلة للتمييز في الطلبات القادمة.
$updateStmt = $con->prepare('UPDATE tokens SET fingerPrint = :fp WHERE passengerID = :pid');
$updateStmt->execute([':fp' => $newHash, ':pid' => $passengerId]);
$updateStmt->execute([':fp' => $fingerprint, ':pid' => $passengerId]);
$fpVerified = true;
securityLog("Legacy GCM fingerprint migrated to CBC", [
'passengerId' => $passengerId,
]);
}
}
+9 -13
View File
@@ -9,26 +9,21 @@ header('Content-Type: application/json');
// ✅ FIX H-03: allowlist صارم للـ Admin Origins
$allowedOrigins = array_filter([
getenv('ALLOWED_ORIGIN') ?: 'https://siromove.com',
'https://jordan-siro.intaleqapp.com',
'http://localhost',
'http://127.0.0.1',
]);
$requestOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (!empty($requestOrigin)) {
if (in_array($requestOrigin, $allowedOrigins, true)) {
header("Access-Control-Allow-Origin: " . $requestOrigin);
} else {
header("Access-Control-Allow-Origin: https://siromove.com");
}
$isLocal = strpos($requestOrigin, 'http://localhost') === 0 || strpos($requestOrigin, 'http://127.0.0.1') === 0;
}
header("Access-Control-Allow-Credentials: true");
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization");
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
// ── Rate Limiting ───────────────────────────────────────────
$limiter = new RateLimiter($redis);
@@ -41,7 +36,8 @@ try {
$allowed1 = getenv('allowedDriver1');
$allowed2 = getenv('allowedDriver2');
$allowedAudiences = array_values(array_filter([$allowed1, $allowed2]));
$allowed3 = getenv('allowedDriverWeb') ;
$allowedAudiences = array_values(array_filter([$allowed1, $allowed2, $allowed3]));
if (empty($id) || empty($password) || empty($audience)) {
jsonError('ID and password are required.', 400);
+4 -7
View File
@@ -9,14 +9,11 @@ ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: https://siromove.com');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
try {
$limiter = new RateLimiter($redis);
+4 -7
View File
@@ -7,14 +7,11 @@
require_once __DIR__ . '/core/bootstrap.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: https://siromove.com');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
try {
$limiter = new RateLimiter($redis);
+4 -7
View File
@@ -6,14 +6,11 @@
require_once __DIR__ . '/core/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: https://siromove.com');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Device-FP');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
$startTime = microtime(true);
+4 -7
View File
@@ -6,14 +6,11 @@
require_once __DIR__ . '/core/bootstrap.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: https://siromove.com');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Device-FP');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
try {
$limiter = new RateLimiter($redis);
@@ -0,0 +1,40 @@
-- ============================================================
-- Blind index columns for searching encrypted fields
-- 2026-07-25
--
-- تُضاف أعمدة بحث حتمية (HMAC) بجانب الأعمدة المشفّرة، حتى يمكن نقل التخزين
-- إلى AES-GCM العشوائي دون فقدان القدرة على البحث.
--
-- آمنة للتشغيل على قاعدة تعمل: كل الأعمدة NULL افتراضياً ولا يقرأها أي كود
-- قبل تشغيل سكربت التعبئة.
-- ============================================================
ALTER TABLE `driver`
ADD COLUMN `phone_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالهاتف',
ADD COLUMN `email_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالبريد',
ADD COLUMN `name_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للاسم الكامل بعد التطبيع',
ADD INDEX `idx_driver_phone_bidx` (`phone_bidx`),
ADD INDEX `idx_driver_email_bidx` (`email_bidx`),
ADD INDEX `idx_driver_name_bidx` (`name_bidx`);
ALTER TABLE `passengers`
ADD COLUMN `phone_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالهاتف',
ADD COLUMN `email_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالبريد',
ADD COLUMN `name_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للاسم الكامل بعد التطبيع',
ADD INDEX `idx_passengers_phone_bidx` (`phone_bidx`),
ADD INDEX `idx_passengers_email_bidx` (`email_bidx`),
ADD INDEX `idx_passengers_name_bidx` (`name_bidx`);
ALTER TABLE `adminUser`
ADD COLUMN `phone_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالهاتف',
ADD COLUMN `email_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالبريد',
ADD INDEX `idx_adminuser_phone_bidx` (`phone_bidx`),
ADD INDEX `idx_adminuser_email_bidx` (`email_bidx`);
-- عمود status مفقود في هذا النشر، وبدونه لا يمكن تتبّع موافقات المشرفين
-- (Admin/Staff/pending.php و auth/approve_admin.php يعتمدان عليه).
-- القيمة الافتراضية 'active' مقصودة حتى لا تُقفل الحسابات القائمة خارج النظام.
ALTER TABLE `adminUser`
ADD COLUMN `status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active | pending | suspended | rejected',
ADD COLUMN `approved_by` VARCHAR(32) NULL DEFAULT NULL,
ADD COLUMN `approved_at` TIMESTAMP NULL DEFAULT NULL;
+4 -1
View File
@@ -33,17 +33,20 @@ try {
global $encryptionHelper;
$encryptedPhone = $encryptionHelper->encryptData($phone);
global $blindIndex;
$phoneBidx = $blindIndex ? $blindIndex->index('driver.phone', $phone) : null;
$stmt = $db->prepare("
SELECT d.id, d.phone, d.first_name, d.last_name, d.status, d.created_at,
cr.id as car_id, cr.make, cr.model, cr.year, cr.car_plate, cr.status as car_status
FROM driver d
LEFT JOIN CarRegistration cr ON cr.driverID = d.id
WHERE d.phone = :phone
WHERE (d.phone = :phone OR (:phone_bidx IS NOT NULL AND d.phone_bidx = :phone_bidx))
LIMIT 1
");
$stmt->execute([
':phone' => $encryptedPhone,
':phone_bidx' => $phoneBidx,
]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
+7 -4
View File
@@ -52,13 +52,16 @@ global $encryptionHelper;
// Resolve user
$encryptedPhone = $encryptionHelper->encryptData($phone);
$driver = $mainDb->prepare("SELECT id, 'driver' AS type FROM driver WHERE phone = :p LIMIT 1");
$driver->execute([':p' => $encryptedPhone]);
global $blindIndex;
$dBidx = $blindIndex ? $blindIndex->index('driver.phone', $phone) : null;
$pBidx = $blindIndex ? $blindIndex->index('passengers.phone', $phone) : null;
$driver = $mainDb->prepare("SELECT id, 'driver' AS type FROM driver WHERE phone = :p OR (:bidx IS NOT NULL AND phone_bidx = :bidx) LIMIT 1");
$driver->execute([':p' => $encryptedPhone, ':bidx' => $dBidx]);
$user = $driver->fetch(PDO::FETCH_ASSOC);
if (!$user) {
$passenger = $mainDb->prepare("SELECT id, 'passenger' AS type FROM passengers WHERE phone = :p LIMIT 1");
$passenger->execute([':p' => $encryptedPhone]);
$passenger = $mainDb->prepare("SELECT id, 'passenger' AS type FROM passengers WHERE phone = :p OR (:bidx IS NOT NULL AND phone_bidx = :bidx) LIMIT 1");
$passenger->execute([':p' => $encryptedPhone, ':bidx' => $pBidx]);
$user = $passenger->fetch(PDO::FETCH_ASSOC);
}
+7 -4
View File
@@ -59,12 +59,15 @@ try {
global $encryptionHelper;
$encryptedPhone = $encryptionHelper->encryptData($phone);
global $blindIndex;
$dBidx = $blindIndex ? $blindIndex->index('driver.phone', $phone) : null;
$pBidx = $blindIndex ? $blindIndex->index('passengers.phone', $phone) : null;
// Look for driver first
$stmt = $db->prepare(
"SELECT id, phone, first_name, last_name FROM driver WHERE phone = :phone LIMIT 1"
"SELECT id, phone, first_name, last_name FROM driver WHERE phone = :phone OR (:bidx IS NOT NULL AND phone_bidx = :bidx) LIMIT 1"
);
$stmt->execute([':phone' => $encryptedPhone]);
$stmt->execute([':phone' => $encryptedPhone, ':bidx' => $dBidx]);
$driver = $stmt->fetch(PDO::FETCH_ASSOC);
if ($driver) {
@@ -86,9 +89,9 @@ try {
// Fallback: look for passenger
$stmt = $db->prepare(
"SELECT id, phone, first_name, last_name FROM passengers WHERE phone = :phone LIMIT 1"
"SELECT id, phone, first_name, last_name FROM passengers WHERE phone = :phone OR (:bidx IS NOT NULL AND phone_bidx = :bidx) LIMIT 1"
);
$stmt->execute([':phone' => $encryptedPhone]);
$stmt->execute([':phone' => $encryptedPhone, ':bidx' => $pBidx]);
$passenger = $stmt->fetch(PDO::FETCH_ASSOC);
if ($passenger) {
+7 -4
View File
@@ -64,14 +64,17 @@ global $encryptionHelper;
// ── Resolve user by phone ────────────────────────────────────
$encryptedPhone = $encryptionHelper->encryptData($phone);
$driverRow = $mainDb->prepare("SELECT id, first_name, last_name FROM driver WHERE phone = :p LIMIT 1");
$driverRow->execute([':p' => $encryptedPhone]);
global $blindIndex;
$dBidx = $blindIndex ? $blindIndex->index('driver.phone', $phone) : null;
$pBidx = $blindIndex ? $blindIndex->index('passengers.phone', $phone) : null;
$driverRow = $mainDb->prepare("SELECT id, first_name, last_name FROM driver WHERE phone = :p OR (:bidx IS NOT NULL AND phone_bidx = :bidx) LIMIT 1");
$driverRow->execute([':p' => $encryptedPhone, ':bidx' => $dBidx]);
$driver = $driverRow->fetch(PDO::FETCH_ASSOC);
$passengerRow = null;
if (!$driver) {
$passengerRow = $mainDb->prepare("SELECT id, first_name, last_name FROM passengers WHERE phone = :p LIMIT 1");
$passengerRow->execute([':p' => $encryptedPhone]);
$passengerRow = $mainDb->prepare("SELECT id, first_name, last_name FROM passengers WHERE phone = :p OR (:bidx IS NOT NULL AND phone_bidx = :bidx) LIMIT 1");
$passengerRow->execute([':p' => $encryptedPhone, ':bidx' => $pBidx]);
$passenger = $passengerRow->fetch(PDO::FETCH_ASSOC);
}
+4 -1
View File
@@ -5,11 +5,14 @@ $phone = filterRequest("phone");
// 🔐 تشفير رقم الهاتف قبل البحث (لأنه مشفّر في قاعدة البيانات)
$phoneEncrypted = $encryptionHelper->encryptData($phone);
global $blindIndex;
$phoneBidx = $blindIndex ? $blindIndex->index('passengers.phone', $phone) : null;
// 1️⃣ جلب passengerID بناءً على رقم الهاتف
$sql = "SELECT `id` FROM `passengers` WHERE `phone` = :phone";
$sql = "SELECT `id` FROM `passengers` WHERE `phone` = :phone OR (:phone_bidx IS NOT NULL AND `phone_bidx` = :phone_bidx)";
$stmt = $con->prepare($sql);
$stmt->bindParam(':phone', $phoneEncrypted);
$stmt->bindParam(':phone_bidx', $phoneBidx);
$stmt->execute();
$data = $stmt->fetch(PDO::FETCH_ASSOC);
+38 -2
View File
@@ -6,6 +6,9 @@ header('Content-Type: application/json; charset=utf-8');
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
$expectedKey = getenv('FCM_INTERNAL_API_KEY');
if (!empty($expectedKey) && !hash_equals($expectedKey, $apiKey)) {
// ‏فشل صامت خطير: التطبيقان لا يرسلان x-api-key إطلاقاً، فلحظة ضبط
// ‏FCM_INTERNAL_API_KEY في البيئة تموت **كل** الرسائل والمكالمات بينهما.
error_log('[SEND_FCM] REJECTED 403 — FCM_INTERNAL_API_KEY set but request sent no x-api-key');
http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
@@ -49,6 +52,10 @@ $tone = $requestData['tone'] ?? 'default';
$customData = $requestData['data'] ?? [];
if (!$target) {
// ‏يعني أن المُرسِل لا يملك توكن الطرف الآخر — عادةً tokenPassenger أو
// ‏driverToken فارغ في التطبيق لأنه لم يصله في حمولة القبول.
error_log('[SEND_FCM] REJECTED 400 — empty target. category='
. (string)($requestData['data']['category'] ?? '?'));
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Missing: target, title, or body.']);
exit;
@@ -92,9 +99,13 @@ function getAccessToken($credentialsPath) {
'assertion' => $jwt
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// بلا مهلة كان الطلب يقدر يعلّق حتى مهلة PHP نفسها، فتُسقط الرسالة بصمت
// وهذا أحد أسباب "الإشعار مرات يوصل ومرات لا".
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
$res = curl_exec($ch);
curl_close($ch);
return json_decode($res, true)['access_token'] ?? null;
}
@@ -114,6 +125,11 @@ $fcmUrl = "https://fcm.googleapis.com/v1/projects/$projectId/messages:send";
// ============================================================================
// بناء هيكل الرسالة
// ============================================================================
// 🔧 توحيد نغمة أندرويد مع FcmService::send — هناك 'ding' تُترجم إلى 'default'،
// وهنا كانت تُمرَّر كما هي فيبحث أندرويد عن ملف صوت اسمه "ding" وقد لا يوجد
// فيصل الإشعار بلا صوت (أو لا يُلفت النظر إطلاقاً).
$androidSound = ($tone === 'ding' || $tone === 'default') ? 'default' : $tone;
$messagePayload = [
'message' => [
'notification' => [
@@ -123,7 +139,7 @@ $messagePayload = [
'android' => [
'priority' => 'HIGH',
'notification' => [
'sound' => $tone,
'sound' => $androidSound,
'channel_id' => 'high_importance_channel' // تأكد من تطابقه مع Android
]
],
@@ -149,6 +165,15 @@ if ($isTopic) {
// ============================================================================
// 🔥 معالجة Data Payload (يجب أن تكون String: String فقط)
// ============================================================================
// FcmService::send يحقن title/body/tone/category/type داخل data، وتطبيق الراكب
// يقرأ message.data['title'] أولاً. هذا المسار كان لا يحقنها، فأي رسالة تمرّ من
// هنا تظهر بعنوان فارغ عند العميل الذي يعتمد على data. نوحّد السلوك.
$customData = array_merge(is_array($customData) ? $customData : [], [
'title' => (string)$title,
'body' => (string)$body,
'tone' => (string)$tone,
]);
if (!empty($customData)) {
$processedData = [];
foreach ($customData as $key => $val) {
@@ -179,6 +204,17 @@ $result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// ‏هذه النقطة هي مسار كل الرسائل والمكالمات بين الراكب والسائق (التطبيقان
// ‏يستدعيانها مباشرة)، ولم تكن تسجّل شيئاً إطلاقاً — بخلاف FcmService التي
// ‏تطبع [FCM_DEBUG]. فكان فشل الرسائل غير قابل للتشخيص: لا سبب ولا رد Google.
$logCategory = (string)($customData['category'] ?? '');
$logTarget = $isTopic ? "topic:$target" : substr((string)$target, 0, 16) . '…';
error_log(
"[SEND_FCM] category=$logCategory target=$logTarget http=$httpCode"
. " len=" . strlen((string)$target)
. ($httpCode == 200 ? '' : " response=$result")
);
// الرد
if ($httpCode == 200) {
echo json_encode([
@@ -5,7 +5,7 @@
require_once __DIR__ . '/../../connect.php';
header("Access-Control-Allow-Origin: https://siromove.com");
header("Content-Type: application/json; charset=UTF-8");
// تفعيل إظهار الأخطاء لمعرفة مشكلة الكتابة
@@ -131,6 +131,21 @@ try {
$ride['first_name'] = $encryptionHelper->decryptData($ride['first_name'] ?? '');
$ride['email'] = $encryptionHelper->decryptData($ride['email'] ?? '');
// جدول tokens يخزّن التوكن مشفّراً (ride/firebase/addToken.php). كان
// يُرجَع هنا كما هو، فيصل تطبيق السائق blob مشفّر يستخدمه كـ FCM target
// في كل رسالة للراكب ⇒ رفض 400 من FCM ولا يصل الراكب شيء.
if (!empty($ride['passengerToken'])) {
$decodedPassengerToken = false;
try {
$decodedPassengerToken = $encryptionHelper->decryptData($ride['passengerToken']);
} catch (\Throwable $eTok) {
$decodedPassengerToken = false;
}
if ($decodedPassengerToken !== false && $decodedPassengerToken !== null && $decodedPassengerToken !== '') {
$ride['passengerToken'] = trim($decodedPassengerToken);
}
}
$ride['start_location'] = $ride['start_lat'] . ',' . $ride['start_lng'];
$ride['end_location'] = (!empty($ride['end_lat']))
? $ride['end_lat'] . ',' . $ride['end_lng']

Some files were not shown because too many files have changed in this diff Show More