db = Database::getInstance(); $this->logger = Logger::getInstance(); $keyFile = dirname(__DIR__, 2) . '/config/firebase-service-account.json'; if (file_exists($keyFile)) { $account = json_decode(file_get_contents($keyFile), true); if ($account && !empty($account['client_email']) && !empty($account['private_key'])) { $this->serviceAccount = $account; $this->mode = 'v1'; $this->logger->info('FCM mode: HTTP v1 (service account)'); return; } } $config = App::getConfig(); $this->serverKey = $config['fcm']['server_key'] ?? $_ENV['FCM_SERVER_KEY'] ?? ''; if (!empty($this->serverKey)) { $this->mode = 'legacy'; $this->logger->info('FCM mode: legacy HTTP API (server key)'); } else { $this->logger->warning('FCM not configured — no service account or server key'); } } public function isConfigured(): bool { return $this->mode !== 'none'; } public function sendToUser(int $userId, string $title, string $body, string $type = 'info', ?int $notificationId = null): bool { if (!$this->isConfigured()) return false; $enabled = $this->db->fetch( 'SELECT notifications_enabled FROM users WHERE id = ? AND notifications_enabled = 1', [$userId] ); if (!$enabled) return false; $tokens = $this->db->fetchAll( 'SELECT id, token FROM user_fcm_tokens WHERE user_id = ?', [$userId] ); if (empty($tokens)) return false; $success = true; foreach ($tokens as $row) { $result = $this->sendToDevice($row['token'], $title, $body, $type, $notificationId); if ($result === false) { $success = false; } elseif ($result === 'invalid_token') { $this->db->delete('user_fcm_tokens', 'id = ?', [(int) $row['id']]); $this->logger->info('FCM: removed invalid token', ['user_id' => $userId]); } } return $success; } public function sendToAll(string $title, string $body, string $type = 'info', ?int $notificationId = null): int { if (!$this->isConfigured()) return 0; $tokens = $this->db->fetchAll( 'SELECT f.id, f.token FROM user_fcm_tokens f JOIN users u ON u.id = f.user_id WHERE u.notifications_enabled = 1' ); if (empty($tokens)) return 0; $sent = 0; foreach ($tokens as $row) { $result = $this->sendToDevice($row['token'], $title, $body, $type, $notificationId); if ($result === true) { $sent++; } elseif ($result === 'invalid_token') { $this->db->delete('user_fcm_tokens', 'id = ?', [(int) $row['id']]); } } return $sent; } private function sendToDevice(string $token, string $title, string $body, string $type, ?int $notificationId): bool|string { if ($this->mode === 'v1') { return $this->sendV1($token, $title, $body, $type, $notificationId); } return $this->sendLegacy($token, $title, $body, $type, $notificationId); } private function sendV1(string $token, string $title, string $body, string $type, ?int $notificationId): bool|string { $accessToken = $this->getAccessToken(); if (!$accessToken) return false; $projectId = $this->serviceAccount['project_id'] ?? ''; if (empty($projectId)) return false; $payload = [ 'message' => [ 'token' => $token, 'notification' => [ 'title' => $title, 'body' => $body, ], 'data' => [ 'type' => $type, 'notification_id' => (string) ($notificationId ?? 0), 'click_action' => 'OPEN_NOTIFICATIONS', ], 'android' => [ 'priority' => 'HIGH', 'notification' => [ 'channel_id' => match ($type) { 'error', 'warning' => 'notifications_alerts', default => 'notifications_general', }, 'sound' => 'default', ], ], ], ]; $ch = curl_init("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . $accessToken, ], CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode === 200) { $this->logger->info('FCM v1: push sent', ['notification_id' => $notificationId]); return true; } if ($httpCode === 404 || $httpCode === 400) { $respData = json_decode($response, true); $errorMsg = $respData['error']['message'] ?? $response; if (str_contains($errorMsg, 'registration-token-not-registered') || str_contains($errorMsg, 'NOT_FOUND') || str_contains($errorMsg, 'INVALID_ARGUMENT')) { $this->logger->warning('FCM v1: invalid token, will remove', ['error' => substr($errorMsg, 0, 200)]); return 'invalid_token'; } } $this->logger->error('FCM v1: send failed', [ 'http_code' => $httpCode, 'response' => substr($response, 0, 500), ]); return false; } private function sendLegacy(string $token, string $title, string $body, string $type, ?int $notificationId): bool|string { $payload = [ 'to' => $token, 'priority' => 'high', 'notification' => [ 'title' => $title, 'body' => $body, 'sound' => 'default', ], 'data' => [ 'type' => $type, 'notification_id' => (string) ($notificationId ?? 0), 'click_action' => 'OPEN_NOTIFICATIONS', ], 'android' => [ 'priority' => 'high', 'notification' => [ 'channel_id' => match ($type) { 'error', 'warning' => 'notifications_alerts', default => 'notifications_general', }, 'priority' => match ($type) { 'error' => 'high', default => 'default', }, 'sound' => 'default', ], ], ]; $ch = curl_init('https://fcm.googleapis.com/fcm/send'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: key=' . $this->serverKey, ], CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode === 200) { $this->logger->info('FCM legacy: push sent', ['notification_id' => $notificationId]); return true; } $respData = json_decode($response, true); $fcmError = $respData['results'][0]['error'] ?? ''; if ($fcmError === 'NotRegistered' || $fcmError === 'InvalidRegistration') { $this->logger->warning('FCM legacy: invalid token, will remove', ['error' => $fcmError]); return 'invalid_token'; } $this->logger->error('FCM legacy: send failed', [ 'http_code' => $httpCode, 'fcm_error' => $fcmError, 'response' => substr($response, 0, 500), ]); return false; } private function getAccessToken(): string { if ($this->accessToken && time() < $this->tokenExpiry) { return $this->accessToken; } $jwt = $this->createJWT(); if (!$jwt) return ''; $ch = curl_init('https://oauth2.googleapis.com/token'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query([ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt, ]), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200) { $this->logger->error('FCM OAuth token request failed', ['http_code' => $httpCode, 'response' => substr($response, 0, 500)]); return ''; } $data = json_decode($response, true); $this->accessToken = $data['access_token'] ?? ''; $this->tokenExpiry = time() + (int) ($data['expires_in'] ?? 3600) - 60; return $this->accessToken; } private function createJWT(): string { if (empty($this->serviceAccount['client_email']) || empty($this->serviceAccount['private_key'])) { return ''; } $now = time(); $header = self::base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); $payload = self::base64UrlEncode(json_encode([ 'iss' => $this->serviceAccount['client_email'], 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', 'aud' => 'https://oauth2.googleapis.com/token', 'exp' => $now + 3600, 'iat' => $now, ])); $signature = ''; openssl_sign("$header.$payload", $signature, $this->serviceAccount['private_key'], 'sha256WithRSAEncryption'); return "$header.$payload." . self::base64UrlEncode($signature); } private static function base64UrlEncode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } }