Food delivery driver module + masked calls + TURN

This commit is contained in:
Hamza-Ayed
2026-08-04 01:50:50 +03:00
parent 04c214bdf6
commit f01e408ba6
35 changed files with 3029 additions and 212 deletions
+14 -2
View File
@@ -4,8 +4,11 @@ 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,
m.longitude AS merchant_lng, m.address AS merchant_address, m.avg_prep_minutes,
o.delivery_fee, o.items_total, o.service_fee, o.discount, o.grand_total, o.payment_method,
o.customer_note, o.delivery_address, o.delivery_lat, o.delivery_lng,
o.created_at, o.ready_at, o.courier_assigned_at, o.picked_up_at,
(SELECT COALESCE(SUM(quantity),0) FROM food_order_items WHERE order_id=o.id) AS items_count,
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
@@ -20,6 +23,15 @@ foreach ($orders as &$o) {
unset($o['delivery_address']);
$o['delivery_address'] = $o['visible_address'];
unset($o['visible_address']);
$o['items_count'] = (int)$o['items_count'];
// نقداً: السائق يحصّل grand_total من الزبون ويحتفظ بأجرته (delivery_fee)،
// والباقي يبقى ديناً عليه حتى التسوية — نُظهر الرقمين صراحةً في التطبيق
// كي لا يجتهد السائق في الحساب على باب الزبون.
$o['cash_to_collect'] = $o['payment_method'] === 'cash' ? (int)$o['grand_total'] : 0;
$o['courier_owes'] = $o['payment_method'] === 'cash'
? (int)$o['grand_total'] - (int)$o['delivery_fee']
: 0;
}
unset($o);
+48
View File
@@ -0,0 +1,48 @@
<?php
// food/courier/call_customer.php — السائق يتصل بالزبون عبر قناة مقنّعة
// لا رقم هاتف يُعرض لأي طرف: نفتح جلسة WebRTC ونُشعر الزبون بمعرّف الجلسة فقط.
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);
if (!foodOrderAllowsCall($order)) {
// بعد التسليم أو قبل الإسناد لا حاجة تشغيلية للاتصال — والقناة تُقفل
jsonError('Calling is only allowed while the delivery is active', 403);
}
if (foodCallQuotaExceeded($orderId, 'courier')) {
jsonError('Too many call attempts for this order', 429);
}
$session = foodCreateCallSession($orderId, $food_courier_id, (string)$order['passenger_id']);
if (!$session) jsonError('Call service unavailable', 502);
// إشعار صامت للزبون عبر موضوع FCM الخاص به — نفس القناة المستعملة في وحدة
// الطعام أصلاً (ممنوع Database::get('main') هنا لجلب توكن الجهاز).
// الاسم المعروض عام عمداً: هوية السائق الحقيقية لا تُكشف للزبون.
foodSendNotificationToPassenger(
(string)$order['passenger_id'],
'مكالمة واردة',
'سائق التوصيل يتصل بك',
[
// تطبيق الراكب يفرز الإشعارات بـ data['category'] — ونُبقي 'type'
// للتوافق مع بقية حمولات وحدة الطعام.
'category' => 'incoming_call',
'type' => 'incoming_call',
'session_id' => $session['session_id'],
'caller_name' => 'سائق التوصيل',
'caller_avatar' => '',
'ride_id' => 'food_' . $orderId,
'food_order_id' => (string)$orderId,
]
);
appLog("[FOOD][CALL] courier {$food_courier_id} → passenger (order {$orderId})", 'INFO');
jsonSuccess([
'session_id' => $session['session_id'],
'expires_in' => $session['expires_in'],
]);
+57
View File
@@ -0,0 +1,57 @@
<?php
// food/courier/earnings.php — ملخص أرباح التوصيل للسائق (اليوم/الأسبوع/الشهر)
// المبالغ من food_orders.delivery_fee للطلبات المسلَّمة فقط — نفس ما يُقيَّد في
// food_order_payments (courier_payout)، والتسوية الفعلية خارج نطاق هذا الملف.
require_once __DIR__ . '/../connect_courier.php';
$sums = $food_con->prepare(
"SELECT
COUNT(*) AS total_orders,
COALESCE(SUM(delivery_fee),0) AS total_earnings,
COALESCE(SUM(CASE WHEN DATE(delivered_at)=CURDATE() THEN delivery_fee END),0) AS today_earnings,
COUNT(CASE WHEN DATE(delivered_at)=CURDATE() THEN 1 END) AS today_orders,
COALESCE(SUM(CASE WHEN delivered_at >= (NOW() - INTERVAL 7 DAY) THEN delivery_fee END),0) AS week_earnings,
COUNT(CASE WHEN delivered_at >= (NOW() - INTERVAL 7 DAY) THEN 1 END) AS week_orders,
COALESCE(SUM(CASE WHEN delivered_at >= (NOW() - INTERVAL 30 DAY) THEN delivery_fee END),0) AS month_earnings,
COUNT(CASE WHEN delivered_at >= (NOW() - INTERVAL 30 DAY) THEN 1 END) AS month_orders
FROM food_orders
WHERE courier_id=? AND status='delivered'"
);
$sums->execute([$food_courier_id]);
$row = $sums->fetch() ?: [];
// ديون التحصيل النقدي غير المسوّاة — السائق يحتاج يعرف كم عليه قبل ما يفاجأ بخصم
$owedSt = $food_con->prepare(
"SELECT COALESCE(SUM(p.amount),0) AS owed
FROM food_order_payments p
JOIN food_orders o ON o.id = p.order_id
WHERE o.courier_id=? AND p.type='cash_settlement' AND p.status='pending'"
);
$owedSt->execute([$food_courier_id]);
// آخر 7 أيام مفصّلة — للرسم البياني في التطبيق
$dailySt = $food_con->prepare(
"SELECT DATE(delivered_at) AS day, COUNT(*) AS orders_count, COALESCE(SUM(delivery_fee),0) AS earnings
FROM food_orders
WHERE courier_id=? AND status='delivered' AND delivered_at >= (NOW() - INTERVAL 7 DAY)
GROUP BY DATE(delivered_at)
ORDER BY day DESC"
);
$dailySt->execute([$food_courier_id]);
jsonSuccess([
'today_earnings' => (int)($row['today_earnings'] ?? 0),
'today_orders' => (int)($row['today_orders'] ?? 0),
'week_earnings' => (int)($row['week_earnings'] ?? 0),
'week_orders' => (int)($row['week_orders'] ?? 0),
'month_earnings' => (int)($row['month_earnings'] ?? 0),
'month_orders' => (int)($row['month_orders'] ?? 0),
'total_earnings' => (int)($row['total_earnings'] ?? 0),
'total_orders' => (int)($row['total_orders'] ?? 0),
'pending_cash_owed' => (int)($owedSt->fetch()['owed'] ?? 0),
'daily' => array_map(static fn($d) => [
'day' => $d['day'],
'orders_count' => (int)$d['orders_count'],
'earnings' => (int)$d['earnings'],
], $dailySt->fetchAll()),
]);
+34
View File
@@ -0,0 +1,34 @@
<?php
// food/courier/history.php — سجل طلبات التوصيل المنتهية لهذا السائق
require_once __DIR__ . '/../connect_courier.php';
$limit = (int)(filterRequest('limit', 'int') ?: 20);
$offset = (int)(filterRequest('offset', 'int') ?: 0);
$limit = max(1, min($limit, 50));
$offset = max(0, $offset);
// العنوان النصّي للزبون لا يُعاد في السجل — انتهت الحاجة التشغيلية إليه بالتسليم،
// ونفس قاعدة الحجب المطبَّقة في active.php.
$st = $food_con->prepare(
"SELECT o.id, o.status, o.merchant_id, m.name_ar AS merchant_name_ar, m.address AS merchant_address,
o.delivery_fee, o.grand_total, o.payment_method, o.rating,
o.courier_assigned_at, o.picked_up_at, o.delivered_at, o.created_at,
(SELECT COALESCE(SUM(quantity),0) FROM food_order_items WHERE order_id=o.id) AS items_count,
TIMESTAMPDIFF(MINUTE, o.courier_assigned_at, o.delivered_at) AS duration_minutes
FROM food_orders o
JOIN food_merchants m ON m.id = o.merchant_id
WHERE o.courier_id = ? AND o.status = 'delivered'
ORDER BY o.delivered_at DESC
LIMIT $limit OFFSET $offset"
);
$st->execute([$food_courier_id]);
$orders = array_map(static function (array $o): array {
$o['items_count'] = (int)$o['items_count'];
$o['delivery_fee'] = (int)$o['delivery_fee'];
$o['grand_total'] = (int)$o['grand_total'];
$o['duration_minutes'] = $o['duration_minutes'] === null ? null : (int)$o['duration_minutes'];
return $o;
}, $st->fetchAll());
jsonSuccess(['orders' => $orders, 'limit' => $limit, 'offset' => $offset]);
+64
View File
@@ -0,0 +1,64 @@
<?php
// food/courier/order_details.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);
$st = $food_con->prepare(
"SELECT o.id, o.status, o.merchant_id, o.items_total, o.delivery_fee, o.service_fee, o.discount,
o.grand_total, o.payment_method, o.customer_note, o.delivery_lat, o.delivery_lng,
o.created_at, o.ready_at, o.courier_assigned_at, o.picked_up_at, o.delivered_at,
CASE WHEN o.status IN ('courier_assigned','picked_up') THEN o.delivery_address ELSE NULL END AS delivery_address,
m.name_ar AS merchant_name_ar, m.address AS merchant_address, m.latitude AS merchant_lat,
m.longitude AS merchant_lng, m.avg_prep_minutes
FROM food_orders o
JOIN food_merchants m ON m.id = o.merchant_id
WHERE o.id = ? LIMIT 1"
);
$st->execute([$orderId]);
$order = $st->fetch();
if (!$order) jsonError('Order not found', 404);
$itemsSt = $food_con->prepare(
"SELECT name_ar_snapshot, quantity, unit_price, line_total, option_price_json
FROM food_order_items WHERE order_id=? ORDER BY id ASC"
);
$itemsSt->execute([$orderId]);
$items = array_map(static function (array $i): array {
return [
'name_ar' => $i['name_ar_snapshot'],
'quantity' => (int)$i['quantity'],
'unit_price' => (int)$i['unit_price'],
'line_total' => (int)$i['line_total'],
'options' => $i['option_price_json'] ? json_decode($i['option_price_json'], true) : null,
];
}, $itemsSt->fetchAll());
// هاتف المطعم — يُعاد فقط أثناء المهمة النشطة (قبل التسليم)، لا في السجل
$merchantPhone = null;
if (in_array($order['status'], ['courier_assigned', 'picked_up'], true)) {
$phoneSt = $food_con->prepare(
"SELECT phone FROM food_merchant_users
WHERE merchant_id=? AND is_active=1 ORDER BY role='owner' DESC, id ASC LIMIT 1"
);
$phoneSt->execute([$order['merchant_id']]);
$merchantPhone = $phoneSt->fetch()['phone'] ?? null;
}
// ملاحظة: لا هاتف للزبون هنا — قاعدة siro_food معزولة ولا تحمل بيانات الراكب،
// وممنوع Database::get('main') داخل backend/food/. التواصل مع الزبون يبقى عبر
// عنوان التسليم والملاحظة، إلى أن تُضاف قناة اتصال مقنّعة كما في الرحلات.
$order['items'] = $items;
$order['items_count'] = array_sum(array_column($items, 'quantity'));
$order['merchant_phone'] = $merchantPhone;
$order['cash_to_collect'] = $order['payment_method'] === 'cash' ? (int)$order['grand_total'] : 0;
$order['courier_owes'] = $order['payment_method'] === 'cash'
? (int)$order['grand_total'] - (int)$order['delivery_fee']
: 0;
jsonSuccess(['order' => $order]);
+15 -5
View File
@@ -5,15 +5,25 @@
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
"SELECT a.order_id, a.offered_at
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)
AND o.status = 'ready' AND o.courier_id IS NULL
ORDER BY a.offered_at ASC"
);
$st->execute([$food_courier_id]);
jsonSuccess(['offers' => $st->fetchAll()]);
// نفس حمولة مسار السوكيت حرفياً (foodBuildCourierOfferPayload) — شاشة العرض
// عند السائق واحدة، فلا يجوز أن تختلف الحقول حسب المسار الذي وصل منه العرض.
$offers = [];
foreach ($st->fetchAll() as $row) {
$payload = foodBuildCourierOfferPayload((int)$row['order_id']);
if (!$payload) continue;
$offers[] = array_merge(
['order_id' => (int)$row['order_id'], 'offered_at' => $row['offered_at'], 'offer_ttl_seconds' => 20],
$payload
);
}
jsonSuccess(['offers' => $offers]);
+32
View File
@@ -0,0 +1,32 @@
<?php
// food/courier/status.php — حالة السائق في وحدة التوصيل عند فتح التبويب
// (وضع التوصيل مخزَّن في Redis لا في التطبيق — بدون هذا النداء يفتح التطبيق
// على "مغلق" بينما السائق ما زال فعلياً في food:couriers:opted_in ويستقبل عروضاً)
require_once __DIR__ . '/../connect_courier.php';
$enabled = false;
if ($redis) {
$enabled = (bool)$redis->sIsMember('food:couriers:opted_in', $food_courier_id);
}
$activeSt = $food_con->prepare(
"SELECT COUNT(*) AS c FROM food_orders
WHERE courier_id=? AND status IN ('courier_assigned','picked_up')"
);
$activeSt->execute([$food_courier_id]);
$activeCount = (int)($activeSt->fetch()['c'] ?? 0);
$todaySt = $food_con->prepare(
"SELECT COUNT(*) AS orders_count, COALESCE(SUM(delivery_fee),0) AS earnings
FROM food_orders
WHERE courier_id=? AND status='delivered' AND DATE(delivered_at)=CURDATE()"
);
$todaySt->execute([$food_courier_id]);
$today = $todaySt->fetch();
jsonSuccess([
'delivery_mode_enabled' => $enabled,
'active_orders_count' => $activeCount,
'today_orders_count' => (int)$today['orders_count'],
'today_earnings' => (int)$today['earnings'],
]);
+132 -2
View File
@@ -179,6 +179,91 @@ function foodSendNotificationToPassenger(string $passengerId, string $title, str
}
}
// ============================================================
// قناة اتصال مقنّعة بين السائق والزبون (WebRTC — بلا أرقام هواتف)
// ------------------------------------------------------------
// لا نكشف رقم أي طرف للآخر إطلاقاً: خادم الإشارات (Node) يفتح جلسة
// بمعرّف عشوائي قصير العمر، والطرفان ينضمّان إليها بـ session_id فقط.
// نستعمل نفس بنية مكالمات الرحلات (VOICE_CALL_SERVER_URL) لكن بمرجع
// جلسة مُسمّى food_{order_id} حتى تبقى سجلات الطعام مميّزة عن الرحلات.
// ============================================================
// حد إساءة الاستخدام: عدد محاولات فتح جلسة لكل طلب/طرف خلال ساعتين
const FOOD_CALL_MAX_PER_ORDER = 10;
function foodCallQuotaExceeded(int $orderId, string $callerRole): bool
{
global $redis;
if (!$redis) return false;
$key = "food:call_quota:{$orderId}:{$callerRole}";
$count = (int)$redis->incr($key);
if ($count === 1) $redis->expire($key, 7200);
if ($count > FOOD_CALL_MAX_PER_ORDER) {
appLog("[FOOD][CALL] quota exceeded order=$orderId role=$callerRole", 'WARNING');
return true;
}
return false;
}
/**
* يفتح جلسة مكالمة على خادم الإشارات ويعيد session_id.
* يعيد null عند أي فشل — المُنادي هو من يقرر رسالة الخطأ للمستخدم.
*/
function foodCreateCallSession(int $orderId, string $courierId, string $passengerId): ?array
{
$url = (getenv('VOICE_CALL_SERVER_URL') ?: 'https://calls.intaleqapp.com') . '/sessions';
$apiKey = getenv('VOICE_CALL_API_KEY') ?: '';
if (!$apiKey) {
appLog('[FOOD][CALL] VOICE_CALL_API_KEY missing — cannot create session', 'ERROR');
return null;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => json_encode([
// خادم الإشارات يعامل ride_id كمعرّف نصّي مبهم — نُميّز طلبات الطعام
'ride_id' => 'food_' . $orderId,
'driver_id' => $courierId,
'passenger_id' => $passengerId,
]),
CURLOPT_HTTPHEADER => ["x-api-key: $apiKey", 'Content-Type: application/json'],
CURLOPT_TIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
appLog("[FOOD][CALL] signaling server failed (HTTP $httpCode): $result", 'ERROR');
return null;
}
$data = json_decode((string)$result, true);
if (!isset($data['session_id'])) {
appLog('[FOOD][CALL] invalid response schema from signaling server', 'ERROR');
return null;
}
return [
'session_id' => (string)$data['session_id'],
'expires_in' => (int)($data['expires_in'] ?? 60),
];
}
// المكالمة مسموحة فقط داخل النافذة التشغيلية للطلب — بعد التسليم تُقفل القناة
function foodOrderAllowsCall(array $order): bool
{
return in_array($order['status'], ['courier_assigned', 'picked_up'], true)
&& !empty($order['courier_id']);
}
// ── Session مطعم (هاتف+كلمة مرور — مستقلة عن JWT، مثل transit) ──
function foodCreateMerchantSession(int $merchantUserId, int $merchantId): string
@@ -433,11 +518,56 @@ function foodOfferOrderToCourier(int $orderId, string $courierId): void
"INSERT INTO food_courier_assignments (order_id, courier_id, status) VALUES (?,?,'offered')"
)->execute([$orderId, $courierId]);
// العرض يُدفع كاملاً عبر السوكيت: شاشة العرض عند السائق تُبنى من هذه الحمولة
// مباشرة بلا نداء HTTP إضافي (مهلة العرض 20 ثانية لا تحتمل round-trip زائد).
$offer = foodBuildCourierOfferPayload($orderId);
// ملاحظة: لا 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]);
// والتطبيق يعتمد أيضاً على courier/pending_offers.php كـ polling fallback عند الانقطاع.
foodPushToSocket('courier_offer', array_merge(
['order_id' => $orderId, 'courier_id' => $courierId, 'offer_ttl_seconds' => 20],
$offer
));
}
// حمولة عرض التوصيل — مصدر واحد يستخدمه السوكيت و pending_offers.php معاً
// حتى لا تختلف الحقول بين المسار اللحظي ومسار الاحتياط.
function foodBuildCourierOfferPayload(int $orderId): array
{
$con = Database::get('food');
$st = $con->prepare(
"SELECT o.id, o.merchant_id, o.delivery_fee, o.grand_total, o.payment_method,
o.delivery_lat, o.delivery_lng, o.items_total, o.created_at,
m.name_ar AS merchant_name_ar, m.address AS merchant_address,
m.latitude AS merchant_lat, m.longitude AS merchant_lng,
(SELECT COALESCE(SUM(quantity),0) FROM food_order_items WHERE order_id=o.id) AS items_count
FROM food_orders o
JOIN food_merchants m ON m.id = o.merchant_id
WHERE o.id = ? LIMIT 1"
);
$st->execute([$orderId]);
$row = $st->fetch();
if (!$row) return [];
// العنوان النصّي للزبون لا يُرسل في العرض — يظهر فقط بعد القبول (active.php).
// نرسل الإحداثيات فقط لحساب المسافة/الاتجاه في شاشة العرض.
return [
'merchant_id' => (int)$row['merchant_id'],
'merchant_name_ar' => $row['merchant_name_ar'],
'merchant_address' => $row['merchant_address'],
'merchant_lat' => (float)$row['merchant_lat'],
'merchant_lng' => (float)$row['merchant_lng'],
'delivery_lat' => (float)$row['delivery_lat'],
'delivery_lng' => (float)$row['delivery_lng'],
'delivery_fee' => (int)$row['delivery_fee'],
'items_count' => (int)$row['items_count'],
'payment_method' => $row['payment_method'],
// نقداً: السائق سيحصّل هذا المبلغ من الزبون — معلومة قرار أساسية قبل القبول
'cash_to_collect' => $row['payment_method'] === 'cash' ? (int)$row['grand_total'] : 0,
'order_created_at' => $row['created_at'],
];
}
// SET NX EX 20 — القابل الأول فقط يفوز، ذرّياً (يمنع سباق القبول)
+39
View File
@@ -0,0 +1,39 @@
<?php
// food/order/call_courier.php — الزبون يتصل بسائق التوصيل عبر قناة مقنّعة
// لا رقم هاتف يُعرض لأي طرف — session_id فقط، والقناة تُقفل بانتهاء الطلب.
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);
if (!foodOrderAllowsCall($order)) {
jsonError('Calling is only allowed while a courier is delivering your order', 403);
}
if (foodCallQuotaExceeded($orderId, 'customer')) {
jsonError('Too many call attempts for this order', 429);
}
$courierId = (string)$order['courier_id'];
$session = foodCreateCallSession($orderId, $courierId, $food_passenger_id);
if (!$session) jsonError('Call service unavailable', 502);
// السائق يصله التنبيه عبر سوكيت الطعام (غرفة courier_food_{id}) — لا موضوع FCM
// خاصاً بكل سائق في النظام، والسوكيت حيّ أصلاً أثناء وضع التوصيل.
// الاسم المعروض عام عمداً: هوية الزبون لا تُكشف للسائق.
foodPushToSocket('courier_call', [
'order_id' => $orderId,
'courier_id' => $courierId,
'session_id' => $session['session_id'],
'caller_name' => 'زبون الطلب #' . $orderId,
'expires_in' => $session['expires_in'],
]);
appLog("[FOOD][CALL] passenger {$food_passenger_id} → courier (order {$orderId})", 'INFO');
jsonSuccess([
'session_id' => $session['session_id'],
'expires_in' => $session['expires_in'],
]);
+108
View File
@@ -0,0 +1,108 @@
<?php
// ═══════════════════════════════════════════════════════════════
// ride/call/turn_credentials.php
// الغرض : بيانات اعتماد مؤقتة لخادم TURN (coturn use-auth-secret)
// التطبيق: السائق والراكب معاً — نفس القناة تخدم مكالمات الرحلات
// ومكالمات توصيل الطعام المقنّعة.
// المصادقة: JWT (driver أو passenger)
// ───────────────────────────────────────────────────────────────
// لماذا بيانات مؤقتة ولا مستخدم ثابت: كلمة مرور TURN ثابتة داخل تطبيق
// منشور = مفتاح تمرير مجاني للجميع بمجرد فكّ حزمة APK. آلية coturn
// القياسية: username = "{انتهاء الصلاحية}:{المستخدم}"، والكلمة
// = base64(HMAC-SHA1(secret, username)). الخادم يتحقق حسابياً بلا
// قاعدة بيانات ولا مزامنة، والصلاحية تنتهي تلقائياً.
// ═══════════════════════════════════════════════════════════════
declare(strict_types=1);
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();
$role = (string)($decoded->role ?? '');
$userId = (string)($decoded->user_id ?? '');
if (!in_array($role, ['driver', 'passenger'], true) || $userId === '') {
jsonError('Forbidden — driver or passenger token required', 403);
}
// السر نفسه المُمرَّر إلى coturn — من /keys أولاً كبقية أسرار المشروع
$secret = '';
$secretPath = getenv('TURN_SECRET_PATH') ?: '/keys/turn_secret';
if (is_readable($secretPath)) {
$secret = trim((string)file_get_contents($secretPath));
}
if ($secret === '') {
$secret = (string)(getenv('TURN_STATIC_AUTH_SECRET') ?: '');
}
$turnHost = getenv('TURN_HOST') ?: '';
// بلا إعداد TURN لا نُفشل المكالمة: نُعيد STUN فقط ويبقى السلوك كما كان
// قبل إضافة TURN (اتصال مباشر ينجح في أغلب الشبكات المنزلية والـ WiFi).
if ($secret === '' || $turnHost === '') {
appLog('[TURN] not configured (missing secret or TURN_HOST) — returning STUN only', 'WARNING');
jsonSuccess([
'ice_servers' => turnDefaultStunServers(),
'ttl' => 0,
'turn_enabled' => false,
]);
}
$ttl = (int)(getenv('TURN_CREDENTIAL_TTL') ?: 43200); // 12 ساعة
$expiry = time() + $ttl;
$username = $expiry . ':' . $role . '_' . $userId;
$password = base64_encode(hash_hmac('sha1', $username, $secret, true));
$turnPort = (int)(getenv('TURN_PORT') ?: 3478);
$turnTlsPort = (int)(getenv('TURN_TLS_PORT') ?: 5349);
// الترتيب مقصود: STUN أولاً (اتصال مباشر أرخص وأقل تأخيراً)، ثم UDP TURN،
// ثم TCP/TLS على 5349 للشبكات التي تحجب UDP كلياً (بعض شبكات الشركات).
$iceServers = turnDefaultStunServers();
$iceServers[] = [
'urls' => "turn:{$turnHost}:{$turnPort}?transport=udp",
'username' => $username,
'credential' => $password,
];
$iceServers[] = [
'urls' => "turn:{$turnHost}:{$turnPort}?transport=tcp",
'username' => $username,
'credential' => $password,
];
// turns: يُعلَن فقط إذا كانت شهادة TLS مركّبة فعلاً على coturn — وإلا صار
// مساراً ميتاً في قائمة ICE يُبطئ التفاوض بلا أن يعمل أبداً.
if (getenv('TURN_TLS_ENABLED') === 'true') {
$iceServers[] = [
'urls' => "turns:{$turnHost}:{$turnTlsPort}?transport=tcp",
'username' => $username,
'credential' => $password,
];
}
jsonSuccess([
'ice_servers' => $iceServers,
'ttl' => $ttl,
'expires_at' => $expiry,
'turn_enabled' => true,
]);
function turnDefaultStunServers(): array
{
$host = getenv('TURN_HOST') ?: '';
$port = (int)(getenv('TURN_PORT') ?: 3478);
// خادمنا يعمل STUN أيضاً — نضعه أولاً، ونُبقي خوادم Google احتياطاً
// إن سقط خادمنا (اكتشاف العنوان العام فقط، لا يمرّ عبره أي صوت).
$servers = [];
if ($host !== '') {
$servers[] = ['urls' => "stun:{$host}:{$port}"];
}
$servers[] = ['urls' => 'stun:stun.l.google.com:19302'];
$servers[] = ['urls' => 'stun:stun1.l.google.com:19302'];
return $servers;
}