Files
Ontime/backendpanel/application/helpers/fcm_v1_helper.php
2026-04-01 11:55:47 +07:00

345 lines
13 KiB
PHP
Executable File

<?php
defined('BASEPATH') or exit('No direct script access allowed');
/**
* FCM HTTP v1 API helper (Firebase Cloud Messaging).
* OAuth2 access token from service account: FCM_CREDENTIALS_PATH or FCM_CREDENTIALS_JSON
* and FCM_PROJECT_ID (set in application/config/config.php).
* Optional: FCM_LIMIT_PER_HOUR, FCM_LIMIT_PER_DAY for quota (used by callers).
*/
if (!function_exists('fcm_v1_get_credentials')) {
function fcm_v1_get_credentials()
{
$json = (defined('FCM_CREDENTIALS_JSON') ? FCM_CREDENTIALS_JSON : '');
if ($json !== '') {
log_message('debug', 'FCM v1: loading credentials from FCM_CREDENTIALS_JSON');
return json_decode($json, true);
}
$path = (defined('FCM_CREDENTIALS_PATH') ? FCM_CREDENTIALS_PATH : '');
if ($path !== '') {
if (is_readable($path)) {
log_message('debug', 'FCM v1: loading credentials from path=' . $path);
return json_decode(file_get_contents($path), true);
} else {
log_message('error', 'FCM v1: credentials path not readable: ' . $path);
}
}
return null;
}
}
if (!function_exists('fcm_v1_get_access_token')) {
/**
* Get OAuth2 access token for FCM using service account JWT.
* Uses ngojol-trial-firebase-adminsdk JSON credentials.
* Retries once on failure (relogin to Firebase).
* @return string|null Access token or null on failure
*/
function fcm_v1_get_access_token()
{
$token = fcm_v1_fetch_access_token();
if ($token) {
return $token;
}
log_message('debug', 'FCM v1: Retrying Firebase auth (relogin)');
return fcm_v1_fetch_access_token();
}
}
if (!function_exists('fcm_v1_fetch_access_token')) {
/**
* Single attempt to fetch OAuth2 access token from Firebase service account.
* @return string|null Access token or null on failure
*/
function fcm_v1_fetch_access_token()
{
$cred = fcm_v1_get_credentials();
if (!$cred || empty($cred['client_email']) || empty($cred['private_key'])) {
log_message('error', 'FCM v1: Missing credentials. Ensure ngojol-trial-firebase-adminsdk JSON is at FCM_CREDENTIALS_PATH');
return null;
}
$now = time();
$token_uri = !empty($cred['token_uri']) ? $cred['token_uri'] : 'https://oauth2.googleapis.com/token';
// Use cloud-platform scope. If you get invalid_scope, enable Firebase Cloud Messaging API
// in Google Cloud Console (APIs & Services → Library) for project ngojol-trial.
$scope = 'https://www.googleapis.com/auth/cloud-platform';
$jwt = array(
'iss' => $cred['client_email'],
'sub' => $cred['client_email'],
'aud' => 'https://oauth2.googleapis.com/token',
'iat' => $now,
'exp' => $now + 3600,
'scope' => $scope,
);
$jwt_b64 = fcm_v1_base64url_encode(json_encode(array('alg' => 'RS256', 'typ' => 'JWT')))
. '.' . fcm_v1_base64url_encode(json_encode($jwt));
$sig = '';
$pk = $cred['private_key'];
if (strpos($pk, '\\n') !== false) {
$pk = str_replace('\\n', "\n", $pk);
}
$key = openssl_pkey_get_private($pk);
if (!$key) {
log_message('error', 'FCM v1: Invalid private key in service account JSON');
return null;
}
openssl_sign($jwt_b64, $sig, $key, OPENSSL_ALGO_SHA256);
openssl_free_key($key);
$jwt_b64 .= '.' . fcm_v1_base64url_encode($sig);
$post_body = 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=' . rawurlencode($jwt_b64);
$ctx = stream_context_create(array(
'http' => array(
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded",
'content' => $post_body,
'ignore_errors' => true,
),
));
$resp = @file_get_contents($token_uri, false, $ctx);
if ($resp === false) {
log_message('error', 'FCM v1: Token request failed (network or OAuth error)');
return null;
}
$data = json_decode($resp, true);
if (isset($data['access_token'])) {
return $data['access_token'];
}
log_message('error', 'FCM v1: Token response invalid: ' . substr($resp, 0, 200));
return null;
}
}
if (!function_exists('fcm_v1_validate_token')) {
/**
* Validate that Firebase auth token can be obtained (for FCM send).
* Call before kirim/send. If false, backend should prompt relogin/retry.
* @return bool True if token is valid and ready for FCM
*/
function fcm_v1_validate_token()
{
$token = fcm_v1_get_access_token();
return !empty($token);
}
}
if (!function_exists('fcm_v1_is_valid_device_token')) {
/**
* Check if string looks like a valid FCM v1 device token (not a placeholder).
* Valid tokens from FirebaseMessaging.getToken() are typically 100+ chars.
* Placeholders like "12345", "R1234567890" are rejected. Use relogin to update.
*
* @param string|null $token FCM device token from client
* @return bool True if token looks valid and should be saved
*/
function fcm_v1_is_valid_device_token($token)
{
$s = $token === null ? '' : trim((string) $token);
if ($s === '') return false;
if (strlen($s) < 50) return false;
if (preg_match('/^(12345|R\d+)$/', $s)) return false;
return true;
}
}
if (!function_exists('fcm_v1_base64url_encode')) {
function fcm_v1_base64url_encode($data)
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}
if (!function_exists('fcm_v1_send')) {
/**
* Send FCM message via HTTP v1 API.
*
* @param string $target Device token or topic name (without /topics/ prefix)
* @param array $data Key-value data payload (values will be stringified)
* @param bool $is_topic True if $target is a topic name
* @param array $options Optional: 'title', 'body' for notification; 'priority' => 'high' for Android
* @return string|false Response body or false on failure
*/
function fcm_v1_send($target, $data, $is_topic = false, $options = array())
{
$project_id = '';
if (defined('FCM_PROJECT_ID') && FCM_PROJECT_ID !== '') {
$project_id = FCM_PROJECT_ID;
}
if ($project_id === '') {
log_message('error', 'FCM v1: FCM_PROJECT_ID not set');
return false;
}
$token = fcm_v1_get_access_token();
if (!$token) {
return false;
}
$data_str = array();
foreach ($data as $k => $v) {
$data_str[$k] = (string) $v;
}
$message = array(
'data' => $data_str,
);
if ($is_topic) {
$message['topic'] = $target;
} else {
$message['token'] = $target;
}
if (!empty($options['title']) || !empty($options['body'])) {
$message['notification'] = array(
'title' => isset($options['title']) ? (string) $options['title'] : '',
'body' => isset($options['body']) ? (string) $options['body'] : '',
);
}
if (isset($options['android']) && is_array($options['android'])) {
$message['android'] = $options['android'];
} else {
$message['android'] = array('priority' => 'HIGH');
}
$body = json_encode(array('message' => $message));
// Log request (without auth token) for debugging
$logTarget = $is_topic ? ('topic=' . (string)$target) : ('token=' . substr((string)$target, 0, 20) . '...');
log_message(
'debug',
'FCM v1 request project_id=' . $project_id .
' ' . $logTarget .
' payload=' . $body
);
$ctx = stream_context_create(array(
'http' => array(
'method' => 'POST',
'header' =>
"Content-Type: application/json\r\n" .
"Authorization: Bearer " . $token . "\r\n",
'content' => $body,
'ignore_errors' => true,
),
));
$url = 'https://fcm.googleapis.com/v1/projects/' . $project_id . '/messages:send';
$resp = @file_get_contents($url, false, $ctx);
if ($resp === false) {
log_message('error', 'FCM v1 response error for ' . $logTarget);
return false;
}
// Check for FCM error in response body (e.g. invalid topic, quota)
$decoded = json_decode($resp, true);
if (is_array($decoded) && isset($decoded['error'])) {
log_message('error', 'FCM v1 API error for ' . $logTarget . ': ' . $resp);
return false;
}
// On 401 (token expired), relogin and retry once
$http_response_header_local = isset($http_response_header) ? $http_response_header : array();
$is_401 = false;
foreach ($http_response_header_local as $h) {
if (preg_match('/^HTTP\/\d\.\d\s+401\b/', $h)) {
$is_401 = true;
break;
}
}
if ($is_401) {
log_message('debug', 'FCM v1: 401 received, relogin and retry');
$token = fcm_v1_get_access_token();
if ($token) {
$ctx = stream_context_create(array(
'http' => array(
'method' => 'POST',
'header' =>
"Content-Type: application/json\r\n" .
"Authorization: Bearer " . $token . "\r\n",
'content' => $body,
'ignore_errors' => true,
),
));
$resp = @file_get_contents($url, false, $ctx);
}
}
log_message('debug', 'FCM v1 response for ' . $logTarget . ' body=' . $resp);
// Treat FCM error in response body as failure
if (is_string($resp)) {
$decoded = json_decode($resp, true);
if (is_array($decoded) && isset($decoded['error'])) {
log_message('error', 'FCM v1 API error: ' . (isset($decoded['error']['message']) ? $decoded['error']['message'] : $resp));
return false;
}
}
return $resp;
}
}
if (!function_exists('fcm_v1_device_token_from_request')) {
/**
* FCM device registration token from API JSON body (mobile APK).
* First non-empty among: firebase_token, fcm_token, reg_id, token.
*
* @param object|null $decoded_data json_decode() result
* @return string Trimmed token or empty string
*/
function fcm_v1_device_token_from_request($decoded_data)
{
if (!$decoded_data || !is_object($decoded_data)) {
return '';
}
foreach (array('firebase_token', 'fcm_token', 'reg_id', 'token') as $key) {
if (!isset($decoded_data->$key)) {
continue;
}
$v = trim((string) $decoded_data->$key);
if ($v !== '') {
return $v;
}
}
return '';
}
}
if (!function_exists('fcm_v1_login_allow_missing_device_token')) {
/**
* When true, Pelanggan/Merchant/Driver login skips FCM token presence validation (e.g. Postman / legacy).
* Controlled by FCM_LOGIN_ALLOW_NO_DEVICE_TOKEN in application/config/config.php.
*/
function fcm_v1_login_allow_missing_device_token()
{
if (defined('FCM_LOGIN_ALLOW_NO_DEVICE_TOKEN')) {
return (bool) FCM_LOGIN_ALLOW_NO_DEVICE_TOKEN;
}
return false;
}
}
if (!function_exists('fcm_v1_validate_login_device_token_from_app')) {
/**
* Enforces that the APK sent a Firebase-generated device token on login.
* Token is produced only on-device (FirebaseMessaging.getToken); backend never mints it.
*
* @param object|null $decoded_data json_decode body
* @return array|null Null if OK or enforcement disabled; else array with keys code, message for API response
*/
function fcm_v1_validate_login_device_token_from_app($decoded_data)
{
if (fcm_v1_login_allow_missing_device_token()) {
return null;
}
if (!function_exists('fcm_v1_device_token_from_request')) {
return null;
}
$raw = fcm_v1_device_token_from_request($decoded_data);
if ($raw === '') {
return array(
'code' => '400',
'message' =>
'Wajib kirim token FCM dari aplikasi (firebase_token, fcm_token, token, atau reg_id). '
. 'Token dibuat oleh Firebase di perangkat setelah Firebase.initializeApp dan izin notifikasi.',
);
}
if (!function_exists('fcm_v1_is_valid_device_token') || !fcm_v1_is_valid_device_token($raw)) {
return array(
'code' => '400',
'message' =>
'Token FCM tidak valid. Pastikan aplikasi berhasil memanggil Firebase Messaging getToken() '
. 'di perangkat (bukan string buatan server).',
);
}
return null;
}
}