$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; } }