Broadcast: ride/firebase/send_fcm.php is an internal service guarded by a
shared secret, so the browser cannot call it — holding that key client-side
would expose it, and the endpoint cannot tell who the sender is. A new
Admin/notifications/broadcast.php sits in front of it: it runs behind
connect.php, requires super_admin, restricts the target to the two topics the
apps actually subscribe to ('drivers'/'passengers') so it cannot be used to
push to an arbitrary topic or a single device token, bounds the title and
body, writes an audit entry before dispatching, and only then forwards the
call internally with the shared secret.
The composer shows a live push preview and an explicit confirmation naming
the audience, since a broadcast cannot be recalled.
Route approvals: draft routes render with their stops, distance and stop
count, and approve/reject posts to transit/route/approve.php behind a
confirmation stating the consequence. Available to admins and super admins,
matching the endpoint's own role check.
Also render user-supplied text with unicode-bidi: plaintext — Arabic names,
addresses and messages were being laid out left-to-right inside the
English UI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
123 lines
4.4 KiB
PHP
123 lines
4.4 KiB
PHP
<?php
|
|
/**
|
|
* Admin/notifications/broadcast.php
|
|
* إرسال إشعار جماعي إلى كل السائقين أو كل الركاب.
|
|
*
|
|
* لماذا نقطة وسيطة بدل استدعاء ride/firebase/send_fcm.php من الواجهة؟
|
|
* - send_fcm.php داخلية ومحمية بمفتاح سرّي (FCM_INTERNAL_API_KEY)، ولا يجوز
|
|
* أن يحمل المتصفح هذا المفتاح لأنه سيُكشف لأي مستخدم.
|
|
* - send_fcm.php لا تعرف من المُرسِل، فلا تستطيع تقييد الصلاحية ولا التدقيق.
|
|
*
|
|
* هذه النقطة تفرض JWT + بصمة الجهاز (عبر connect.php) ودور super_admin، ثم
|
|
* تُمرّر الطلب داخلياً مع المفتاح السرّي وتسجّل العملية في سجل التدقيق.
|
|
*/
|
|
|
|
require_once __DIR__ . '/../../connect.php';
|
|
|
|
// إشعار جماعي يصل كل مستخدمي المنصة فوراً ولا يمكن سحبه بعد الإرسال.
|
|
if ($role !== 'super_admin') {
|
|
http_response_code(403);
|
|
echo json_encode([
|
|
'status' => 'failure',
|
|
'message' => 'Forbidden. Super Admin access required to broadcast notifications.',
|
|
], JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
$audience = filterRequest('audience');
|
|
$title = filterRequest('title');
|
|
$body = filterRequest('body');
|
|
|
|
// المواضيع المسموح بها فقط — يشترك بها التطبيقان (siro_driver / siro_rider).
|
|
// قصرها على قائمة ثابتة يمنع استخدام النقطة لبثّ رسائل إلى مواضيع عشوائية
|
|
// أو إلى توكن جهاز بعينه.
|
|
$ALLOWED_AUDIENCES = [
|
|
'drivers' => 'drivers',
|
|
'passengers' => 'passengers',
|
|
];
|
|
|
|
if (!isset($ALLOWED_AUDIENCES[$audience])) {
|
|
jsonError('Invalid audience. Allowed: ' . implode(', ', array_keys($ALLOWED_AUDIENCES)), 400);
|
|
}
|
|
|
|
$title = trim((string) $title);
|
|
$body = trim((string) $body);
|
|
|
|
if ($title === '' || $body === '') {
|
|
jsonError('Both title and body are required.', 400);
|
|
}
|
|
if (mb_strlen($title) > 120) {
|
|
jsonError('Title is too long (max 120 characters).', 400);
|
|
}
|
|
if (mb_strlen($body) > 1000) {
|
|
jsonError('Body is too long (max 1000 characters).', 400);
|
|
}
|
|
|
|
$topic = $ALLOWED_AUDIENCES[$audience];
|
|
|
|
// سجل التدقيق قبل الإرسال: نريد أثراً حتى لو فشل النداء أو انقطع.
|
|
securityLog("Broadcast notification requested", [
|
|
'user_id' => $user_id ?? 'unknown',
|
|
'audience' => $audience,
|
|
'title' => $title,
|
|
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
|
]);
|
|
|
|
if (function_exists('logAudit')) {
|
|
try {
|
|
logAudit($con, (string) ($user_id ?? 'unknown'), 'إرسال إشعار جماعي', 'notification', $topic, [
|
|
'audience' => $audience,
|
|
'title' => $title,
|
|
'body' => $body,
|
|
]);
|
|
} catch (Throwable $e) {
|
|
error_log("[Broadcast] audit log failed: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
// الاستدعاء الداخلي لخدمة FCM
|
|
$fcmUrl = getenv('FCM_INTERNAL_URL') ?: 'http://127.0.0.1/backend/ride/firebase/send_fcm.php';
|
|
$payload = json_encode([
|
|
'target' => $topic,
|
|
'title' => $title,
|
|
'body' => $body,
|
|
'isTopic' => true,
|
|
'data' => ['category' => 'admin_broadcast'],
|
|
], JSON_UNESCAPED_UNICODE);
|
|
|
|
$headers = ['Content-Type: application/json; charset=UTF-8'];
|
|
$internalKey = getenv('FCM_INTERNAL_API_KEY');
|
|
if (!empty($internalKey)) {
|
|
$headers[] = 'X-API-KEY: ' . $internalKey;
|
|
}
|
|
|
|
$ch = curl_init($fcmUrl);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => $payload,
|
|
CURLOPT_HTTPHEADER => $headers,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 20,
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$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);
|
|
}
|
|
|
|
$decoded = json_decode((string) $response, true);
|
|
|
|
jsonSuccess([
|
|
'audience' => $audience,
|
|
'topic' => $topic,
|
|
'title' => $title,
|
|
'sent_by' => $user_id ?? null,
|
|
'sent_at' => date('Y-m-d H:i:s'),
|
|
'fcm_status' => $decoded['status'] ?? 'unknown',
|
|
], 'Broadcast delivered to the notification service.');
|