Log.print used developer.log with no kDebugMode guard, so release
builds emitted wallet JWTs, the HMAC secret, phone numbers and full
API responses to os_log/logcat on the user's device. Both the rider
and driver apps were affected.
Also removes a hardcoded test-account condition in the rider login
flow that skipped the entire FCM-token/fingerprint comparison — and
therefore the device-change OTP — for one email address.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
السبب الجذري: 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>
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>
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>
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>
في لوج الإنتاج ثلاثة اتصالات متزامنة للسائق نفسه (التطبيق يفتح سوكيتاً من
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>
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>
الموضعان كانا يقرآن مفتاحاً لا يُكتب في أي مكان في المشروع:
$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>
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>
السبب (مثبَّت على الإنتاج): التزام 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>
استرجاع شبكة أمان موقع السائق (انحدارة من الالتزام 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>
سوكيت الراكب:
- 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>
كان السوكيت مضموناً أن يخسر السباق الأول بنيوياً. ترتيب
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>
تطبيق الراكب يطلب /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>
سوكيت الراكب (سبب عدم ظهور معلومات السائق عند القبول):
- تطبيق الراكب كان يرسل 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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
serviceapp/updatePackages.php built its UPDATE by interpolating the request
values straight into the SQL string, so any caller with a valid token could
execute arbitrary SQL through the version field. It now uses bound
parameters, requires an admin role, validates the version format, and writes
an audit entry.
trigger_campaign.php gains dry_run=1: it performs the same Gemini analysis
and target selection but returns before creating the promo code and before
dispatching any notification. Launching without previewing was the only
option before, and a launch writes a seven-day discount and pushes to every
passenger in the country.
Console:
- Campaign launcher with a mandatory preview. Launching stays disabled until
the current parameters have been previewed, and re-locks if any parameter
changes afterwards or once a launch completes.
- App version manager with the same version-format check as the server and a
confirmation naming the old and new values.
Cache busting: assets are served straight off a bind mount with no version,
so browsers kept running the previously cached build after a deploy. Both
asset links now carry ?v=, and the build id is shown in Session & Security
and printed in the diagnostics report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Driver documents: paged list from auth/driver/drivers_pending_list.php, a
detail panel showing each uploaded document as a thumbnail linking to the
full image, and activation via Admin/driver/updateDriverFromAdmin.php. The
confirmation states how many documents were reviewed and warns explicitly
when a captain has none on file, since approving then activates an
unverified account.
Details are requested as a POST body. The mobile app calls this endpoint as
GET "?id=", which filterRequest() never reads, so its detail lookup cannot
be receiving an id at all.
Staff: pending admin/service accounts with per-account activation via
Staff/activate.php, the employee list, and a creation form posting to
Staff/add.php. Administrator accounts are offered only to super admins,
matching add.php's own check; passwords are rejected below 8 characters and
cleared from the form after submission.
Both screens mask phone numbers for plain admins and never render
token/password/fingerprint fields.
Also stop .btn-primary stretching to full width when used inline in a card
header — it is styled for the login form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Broadcast: ride/firebase/send_fcm.php is an internal service guarded by a
shared secret, so the browser cannot call it — holding that key client-side
would expose it, and the endpoint cannot tell who the sender is. A new
Admin/notifications/broadcast.php sits in front of it: it runs behind
connect.php, requires super_admin, restricts the target to the two topics the
apps actually subscribe to ('drivers'/'passengers') so it cannot be used to
push to an arbitrary topic or a single device token, bounds the title and
body, writes an audit entry before dispatching, and only then forwards the
call internally with the shared secret.
The composer shows a live push preview and an explicit confirmation naming
the audience, since a broadcast cannot be recalled.
Route approvals: draft routes render with their stops, distance and stop
count, and approve/reject posts to transit/route/approve.php behind a
confirmation stating the consequence. Available to admins and super admins,
matching the endpoint's own role check.
Also render user-supplied text with unicode-bidi: plaintext — Arabic names,
addresses and messages were being laid out left-to-right inside the
English UI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings the console to 18 modules across 26 navigation entries.
- Fleet performance (best captains, per-captain card charges), invoice
totals and promo codes as read-only panels.
- Ride monitor: monitorRide.php and getDriverGiftPayment.php answer only for
a given phone number, so they get an input rather than a panel that would
render an error on load.
- Status-like values in generated tiles use the same label mapping as the
tables, so raw values such as cancelled_by_passenger no longer leak into
the UI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hotfix: a comment added to the dashboard SQL contained double quotes inside
the double-quoted PHP string, terminating it and making dashbord.php fail to
parse. Production was returning a parse error for every dashboard request.
Authorisation gaps closed — connect.php only proves a token is valid, it does
not check what the caller is allowed to do:
- Admin/ggg.php decrypts any database field and was authorised solely by an
admin phone number sent in the request body. Anyone who knew a listed
number could decrypt platform data without signing in. It now runs behind
connect.php, requires super_admin, keeps the phone list as a second factor,
and records every use.
- ride/kazan/update.php, kazan/add.php and ride/promo/{add,update,delete}.php
changed live pricing and discount codes with no role check at all, so any
valid token — including a driver's or passenger's — could rewrite the fare
table. All now require super_admin.
Staff/pending.php: adminUser has no `status` column in this deployment, so
the query failed with an opaque "unavailable". It now checks for the column
and reports the actual reason.
Console: Kazan tariff editor for super admins — sends only changed fields,
shows an old → new confirmation before saving, and stays read-only with an
explanatory notice for plain admins.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ride table holds two generations of status values: the legacy CamelCase
set ('Finished', 'CancelFromPassenger') and the lowercase set written by
backend/ride/rides/* today ('completed', 'cancelled_by_passenger'). Admin
queries only matched the legacy set, so on live data:
- get_rides_by_status.php returned nothing meaningful for every filter, and
the "in progress" default masked it.
- dashbord.php reported total_driver_earnings as NULL, completed_rides as a
fraction of the real count, and cancelled_rides as 0.
- driver_avg_duration averaged in negative durations, yielding "-00h 22m".
All three now match on LOWER(status) across both families.
Staff/pending.php ran with no authentication at all, exposing pending
admins' names and phone numbers to any caller; it now goes through
connect.php with a role check. It also returned HTTP 400 for everything when
the `users` table was absent — each source is queried independently and
reports its own availability.
Console:
- Render rides from either schema generation (price/date/time and
start_location coordinates, or the older address/created_at columns).
- Null aggregates render as "—" rather than a measured 0.00.
- Add tariff/promo, WhatsApp send and encryption modules, all super-admin
gated; pricing remains read-only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>