send_fcm.php هي نقطة كل الرسائل والمكالمات بين الراكب والسائق (التطبيقان يستدعيانها مباشرة)، ولم تكن تحتوي أي error_log إطلاقاً — بخلاف FcmService التي تطبع [FCM_DEBUG] وتخدم دورة حياة الرحلة فقط. النتيجة أن فشل الرسائل كان غير قابل للتشخيص: لا سبب، ولا رد Google، ولا حتى معرفة إن كان الطلب وصل. وقد وعدت المستخدم بقراءة [FCM_DEBUG] لهذا المسار وهو وعد خاطئ — لا وجود له هنا. أُضيفت ثلاث نقاط: - نتيجة الإرسال: category + طرف من التوكن + http + طول التوكن + رد Google عند الفشل. طول التوكن مقصود: توكن FCM الحقيقي ~163 محرفاً، والمشفّر في جدول tokens 216 — فيُكشف أي blob مشفّر من سطر واحد. - رفض 403: التطبيقان لا يرسلان x-api-key، فلحظة ضبط FCM_INTERNAL_API_KEY تموت كل الرسائل بينهما بصمت. الآن يُسجَّل السبب صريحاً. - رفض 400 على target فارغ: يعني أن المُرسِل لا يملك توكن الطرف الآخر (tokenPassenger أو driverToken لم يصله في حمولة القبول). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
234 lines
9.5 KiB
PHP
234 lines
9.5 KiB
PHP
<?php
|
||
// send_fcm.php - FCM HTTP v1 Sender (Internal use only)
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
|
||
// 🔐 Require internal API key for authentication if set
|
||
$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;
|
||
}
|
||
|
||
function resolveServiceAccountPath(): string {
|
||
$envPath = getenv('FIREBASE_SERVICE_ACCOUNT_PATH') ?: '';
|
||
if (!empty($envPath) && file_exists($envPath)) {
|
||
return $envPath;
|
||
}
|
||
// if (file_exists('/keys/firebase_service_account.json')) {
|
||
// return '/keys/firebase_service_account.json';
|
||
// }
|
||
if (file_exists('/keys/service-account.json')) {
|
||
return '/keys/service-account.json';
|
||
}
|
||
// if (file_exists(__DIR__ . '/../../keys/firebase_service_account.json')) {
|
||
// return __DIR__ . '/../../keys/firebase_service_account.json';
|
||
// }
|
||
return __DIR__ . '/service-account.json';
|
||
}
|
||
|
||
$serviceAccountFile = resolveServiceAccountPath();
|
||
|
||
// السماح فقط بـ POST
|
||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(405);
|
||
echo json_encode(['status' => 'error', 'message' => 'Only POST allowed.']);
|
||
exit;
|
||
}
|
||
|
||
// استقبال البيانات
|
||
$json_input = file_get_contents('php://input');
|
||
$requestData = json_decode($json_input, true);
|
||
|
||
$target = $requestData['target'] ?? null;
|
||
$title = $requestData['title'] ?? null;
|
||
$body = $requestData['body'] ?? null;
|
||
$isTopic = $requestData['isTopic'] ?? false;
|
||
$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;
|
||
}
|
||
|
||
// ============================================================================
|
||
// دالة Base64 URL-Safe Encoding (ضرورية للـ JWT)
|
||
// ============================================================================
|
||
function base64UrlEncode($data) {
|
||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||
}
|
||
|
||
// ============================================================================
|
||
// دالة المصادقة (Google OAuth2)
|
||
// ============================================================================
|
||
function getAccessToken($credentialsPath) {
|
||
if (!file_exists($credentialsPath)) return null;
|
||
|
||
$credentials = json_decode(file_get_contents($credentialsPath), true);
|
||
$clientEmail = $credentials['client_email'];
|
||
$privateKey = $credentials['private_key'];
|
||
|
||
$now = time();
|
||
$header = base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
|
||
$claim = base64UrlEncode(json_encode([
|
||
'iss' => $clientEmail,
|
||
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
|
||
'aud' => 'https://oauth2.googleapis.com/token',
|
||
'exp' => $now + 3600,
|
||
'iat' => $now
|
||
]));
|
||
|
||
$signature = '';
|
||
openssl_sign("$header.$claim", $signature, $privateKey, 'SHA256');
|
||
$jwt = "$header.$claim." . base64UrlEncode($signature);
|
||
|
||
$ch = curl_init("https://oauth2.googleapis.com/token");
|
||
curl_setopt($ch, CURLOPT_POST, 1);
|
||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
|
||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||
'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;
|
||
}
|
||
|
||
// الحصول على Access Token
|
||
$accessToken = getAccessToken($serviceAccountFile);
|
||
if (!$accessToken) {
|
||
http_response_code(500);
|
||
echo json_encode(['status' => 'error', 'message' => 'Failed to get Access Token.']);
|
||
exit;
|
||
}
|
||
|
||
// جلب Project ID
|
||
$creds = json_decode(file_get_contents($serviceAccountFile), true);
|
||
$projectId = $creds['project_id'];
|
||
$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' => [
|
||
'title' => $title,
|
||
'body' => $body
|
||
],
|
||
'android' => [
|
||
'priority' => 'HIGH',
|
||
'notification' => [
|
||
'sound' => $androidSound,
|
||
'channel_id' => 'high_importance_channel' // تأكد من تطابقه مع Android
|
||
]
|
||
],
|
||
'apns' => [
|
||
'headers' => ['apns-priority' => '10'],
|
||
'payload' => [
|
||
'aps' => [
|
||
'sound' => ($tone === 'ding' || $tone === 'default') ? 'default' : (str_ends_with($tone, '.caf') ? $tone : $tone . '.caf'),
|
||
'content-available' => 1
|
||
]
|
||
]
|
||
]
|
||
]
|
||
];
|
||
|
||
// تحديد الهدف (Topic أو Token)
|
||
if ($isTopic) {
|
||
$messagePayload['message']['topic'] = $target;
|
||
} else {
|
||
$messagePayload['message']['token'] = $target;
|
||
}
|
||
|
||
// ============================================================================
|
||
// 🔥 معالجة 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) {
|
||
if (is_array($val) || is_object($val)) {
|
||
// تحويل المصفوفات/الكائنات إلى JSON String
|
||
$processedData[$key] = json_encode($val, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
} else {
|
||
// تحويل أي قيمة أخرى إلى String
|
||
$processedData[$key] = (string)$val;
|
||
}
|
||
}
|
||
$messagePayload['message']['data'] = $processedData;
|
||
}
|
||
|
||
// ============================================================================
|
||
// الإرسال الفعلي إلى FCM
|
||
// ============================================================================
|
||
$ch = curl_init($fcmUrl);
|
||
curl_setopt($ch, CURLOPT_POST, true);
|
||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||
'Authorization: Bearer ' . $accessToken,
|
||
'Content-Type: application/json; charset=UTF-8'
|
||
]);
|
||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($messagePayload, JSON_UNESCAPED_UNICODE));
|
||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||
|
||
$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([
|
||
'status' => 'success',
|
||
'message' => 'Notification sent successfully',
|
||
'fcm_response' => json_decode($result)
|
||
], JSON_UNESCAPED_UNICODE);
|
||
} else {
|
||
http_response_code($httpCode);
|
||
echo json_encode([
|
||
'status' => 'error',
|
||
'message' => 'FCM request failed',
|
||
'fcm_response' => json_decode($result)
|
||
], JSON_UNESCAPED_UNICODE);
|
||
}
|
||
?>
|