Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4620e84d34 | ||
|
|
b78a6797d5 | ||
|
|
811b23ca9e | ||
|
|
915d517ba7 | ||
|
|
12fc64dc9a | ||
|
|
46e74330a1 | ||
|
|
410fb14d77 | ||
|
|
cf3fea3834 | ||
|
|
0452c36379 | ||
|
|
7362f1ce96 | ||
|
|
8d67530b44 | ||
|
|
f94a9478ac | ||
|
|
7a576b7327 | ||
|
|
ecfe756849 | ||
|
|
4d2beaae5e | ||
|
|
b84e26fe9a | ||
|
|
77c0b48ec2 | ||
|
|
e106d2df4e | ||
|
|
899a18b52d | ||
|
|
e6504acdea | ||
|
|
ef1240b130 | ||
|
|
0e6dd68385 | ||
|
|
afb5189515 | ||
|
|
6fec88251c | ||
|
|
b76eccb763 | ||
|
|
a26472ed9f | ||
|
|
ab3be7e2e3 | ||
|
|
f66db7db42 | ||
|
|
143146c1b4 | ||
|
|
e269cb9b70 | ||
|
|
330dfc6dba | ||
|
|
4b5235c35c | ||
|
|
16feb51a75 | ||
|
|
3af99cc18a | ||
|
|
7830efead7 | ||
|
|
ca6cb7a3fe | ||
|
|
f325ffce42 | ||
|
|
c43f801542 | ||
|
|
2bacb1b9e1 | ||
|
|
5ec54c03f5 | ||
|
|
a954f49307 | ||
|
|
91fe0f78f7 | ||
|
|
5f5b68a8cd | ||
|
|
1664743ef9 | ||
|
|
1dfc302a4f | ||
|
|
76c8652bf0 | ||
|
|
a095472f39 | ||
|
|
c8a9e98ff5 | ||
|
|
e03b9c30d5 | ||
|
|
20ea9aa12c | ||
|
|
8d3e63d1c7 | ||
|
|
4bbc687c15 | ||
|
|
5e103f60f2 | ||
|
|
27369b8ac1 | ||
|
|
61d6380861 | ||
|
|
3fb7bc5190 | ||
|
|
f9110a7d92 | ||
|
|
a0812dbd10 | ||
|
|
bc1b0129e8 | ||
|
|
2135edcf43 | ||
|
|
35a66935aa | ||
|
|
8d7e3118b5 | ||
|
|
39b5a7fc7f | ||
|
|
a1c19b052d | ||
|
|
c9b4d14da6 | ||
|
|
15f55ff3e4 | ||
|
|
761b957c96 | ||
|
|
57e22477fb | ||
|
|
6802026dbd | ||
|
|
81ee2acefd |
+2
-2
@@ -70,8 +70,8 @@ DerivedData/
|
||||
xcuserdata/
|
||||
|
||||
# --- Composer / PHP ---
|
||||
/composer.lock
|
||||
**/composer.lock
|
||||
# composer.lock مُتتبَّع عمداً: بدونه ينهار payment_server/v2 بعد أي نشر نظيف
|
||||
# (vendor يبقى مستثنى أعلاه — يُبنى بـ composer install من الـ lock)
|
||||
|
||||
# --- Logs ---
|
||||
*.log
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -4,7 +4,7 @@ require_once __DIR__ . '/../../../connect.php';
|
||||
$phoneNumber = filterRequest("phone_number");
|
||||
|
||||
// تشفير الرقم قبل البحث
|
||||
$phoneNumber_encrypted = $encryptionHelper->encryptData($phoneNumber);
|
||||
$phoneNumber_encrypted = otpPhoneKey($phoneNumber);
|
||||
|
||||
try {
|
||||
// الاستعلام عن السائق حسب رقم الهاتف وحالة التحقق
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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']);
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+21
-15
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 طلب / دقيقة (الإنتاج الرسمى)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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]);
|
||||
@@ -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');
|
||||
@@ -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()]);
|
||||
@@ -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');
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'];
|
||||
@@ -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]);
|
||||
@@ -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']);
|
||||
@@ -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']);
|
||||
@@ -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()]);
|
||||
@@ -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]);
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
@@ -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]);
|
||||
@@ -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()]);
|
||||
@@ -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');
|
||||
@@ -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']);
|
||||
@@ -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]);
|
||||
@@ -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']);
|
||||
@@ -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]);
|
||||
@@ -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']);
|
||||
@@ -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']);
|
||||
@@ -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');
|
||||
@@ -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]);
|
||||
@@ -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]);
|
||||
@@ -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]);
|
||||
@@ -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');
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user