diff --git a/src/Libraries/FirebaseClient.php b/src/Libraries/FirebaseClient.php new file mode 100644 index 0000000..aaee89c --- /dev/null +++ b/src/Libraries/FirebaseClient.php @@ -0,0 +1,393 @@ + + * @license https://mit-license.org/ MIT License + * @version GIT: 0.3.8 + * @link https://github.com/spotlibs + */ + +declare(strict_types=1); + +namespace Spotlibs\PhpLib\Libraries; + +use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Psr7\Request; +use Psr\Http\Message\ResponseInterface; +use Spotlibs\PhpLib\Exceptions\RuntimeException; +use Spotlibs\PhpLib\Logs\Log; + +/** + * FirebaseClient + * + * SDK for Firebase OAuth and FCM operations with file-based token persistence + * + * @category HttpClient + * @package Client + * @author Mufthi Ryanda + * @license https://mit-license.org/ MIT License + * @link https://github.com/spotlibs + */ +class FirebaseClient +{ + private GuzzleClient $httpClient; + private array $serviceAccount; + private string $proxyUrl = ''; + private string $tokenFile; + + /** + * Create Firebase client + * + * @param array $config Guzzle config options + * + * @throws RuntimeException When FIREBASE_CREDENTIALS env not set + */ + public function __construct(array $config = []) + { + $serviceAccountPath = env('FIREBASE_CREDENTIALS'); + if (empty($serviceAccountPath)) { + throw new RuntimeException('FIREBASE_CREDENTIALS environment variable is not set'); + } + $fullPath = base_path($serviceAccountPath); + if (!file_exists($fullPath)) { + throw new RuntimeException("Firebase credentials file not found: {$fullPath}"); + } + + $this->serviceAccount = json_decode( + file_get_contents($fullPath), + true, + 512, + JSON_THROW_ON_ERROR + ); + + // Set token file path in storage + $this->tokenFile = storage_path('framework/cache/firebase_token.json'); + + // Ensure directory exists + $dir = dirname($this->tokenFile); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $defaultConfig = [ + 'timeout' => 60, + 'verify' => false, + ]; + + $this->httpClient = new GuzzleClient(array_merge($defaultConfig, $config)); + } + + /** + * Set proxy URL + * + * @param string $proxyUrl Proxy URL (e.g., http://proxy:port) + * + * @return self + */ + public function setProxy(string $proxyUrl): self + { + $this->proxyUrl = $proxyUrl; + return $this; + } + + /** + * Get or refresh access token from file + * + * @param bool $forceRefresh Force token regeneration + * + * @return string Access token + * + * @throws \GuzzleHttp\Exception\GuzzleException On HTTP error + */ + private function getAccessToken(bool $forceRefresh = false): string + { + // Try to read existing token + if (!$forceRefresh && file_exists($this->tokenFile)) { + $handle = fopen($this->tokenFile, 'r'); + if ($handle && flock($handle, LOCK_SH)) { + $content = fread($handle, filesize($this->tokenFile)); + flock($handle, LOCK_UN); + fclose($handle); + + $tokenData = json_decode($content, true); + + // Check if token is still valid (with 5 min buffer) + if ($tokenData && isset($tokenData['token'], $tokenData['expiry']) && $tokenData['expiry'] > time() + 300) { + return $tokenData['token']; + } + } elseif ($handle) { + fclose($handle); + } + } + + Log::runtime()->info( + [ + 'operation' => 'firebase_token_refresh', + 'reason' => $forceRefresh ? 'forced' : (!file_exists($this->tokenFile) ? 'empty' : 'expired') + ] + ); + + // Generate new token and save to file + $tokenData = $this->generateToken(); + $this->saveTokenToFile($tokenData); + + return $tokenData['token']; + } + + /** + * Save token data to file with lock + * + * @param array $tokenData Token data with 'token' and 'expiry' keys + * + * @return void + */ + private function saveTokenToFile(array $tokenData): void + { + $handle = fopen($this->tokenFile, 'c'); + if ($handle && flock($handle, LOCK_EX)) { + ftruncate($handle, 0); + fwrite($handle, json_encode($tokenData, JSON_THROW_ON_ERROR)); + fflush($handle); + flock($handle, LOCK_UN); + } + if ($handle) { + fclose($handle); + } + } + + /** + * Generate OAuth2 access token + * + * @return array Array with 'token' and 'expiry' keys + * + * @throws \GuzzleHttp\Exception\GuzzleException On HTTP error + */ + private function generateToken(): array + { + $startTime = microtime(true); + $now = time(); + + $jwt = $this->createJWT( + [ + 'iss' => $this->serviceAccount['client_email'], + 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', + 'aud' => 'https://oauth2.googleapis.com/token', + 'iat' => $now, + 'exp' => $now + 3600 + ], + $this->serviceAccount['private_key'] + ); + + $body = http_build_query( + [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt + ] + ); + + $request = new Request( + 'POST', + 'https://oauth2.googleapis.com/token', + ['Content-Type' => 'application/x-www-form-urlencoded'], + $body + ); + + $options = []; + if (!empty($this->proxyUrl)) { + $options['proxy'] = $this->proxyUrl; + } + + $response = $this->httpClient->send($request, $options); + $elapsed = microtime(true) - $startTime; + + $responseBody = json_decode( + $response->getBody()->getContents(), + true, + 512, + JSON_THROW_ON_ERROR + ); + + $token = $responseBody['access_token']; + $expiresIn = $responseBody['expires_in'] ?? 3600; + $expiry = time() + $expiresIn; + + Log::runtime()->info( + [ + 'operation' => 'firebase_oauth', + 'url' => 'https://oauth2.googleapis.com/token', + 'responseTime' => round($elapsed * 1000), + 'httpCode' => $response->getStatusCode() + ] + ); + + return ['token' => $token, 'expiry' => $expiry]; + } + + /** + * Send FCM message + * + * @param array $message FCM message payload + * + * @return ResponseInterface + * + * @throws \GuzzleHttp\Exception\GuzzleException On HTTP error + */ + public function sendMessage(array $message): ResponseInterface + { + $token = $this->getAccessToken(); + + $startTime = microtime(true); + $projectId = $this->serviceAccount['project_id']; + $url = "https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send"; + + $request = new Request( + 'POST', + $url, + [ + 'Authorization' => 'Bearer ' . $token, + 'Content-Type' => 'application/json' + ], + json_encode(['message' => $message], JSON_THROW_ON_ERROR) + ); + + $options = []; + if (!empty($this->proxyUrl)) { + $options['proxy'] = $this->proxyUrl; + } + + try { + $response = $this->httpClient->send($request, $options); + $elapsed = microtime(true) - $startTime; + + $respBody = $response->getBody()->getContents(); + $response->getBody()->rewind(); + + Log::runtime()->info( + [ + 'operation' => 'firebase_fcm_send', + 'host' => 'fcm.googleapis.com', + 'url' => "/v1/projects/{$projectId}/messages:send", + 'request' => ['body' => $message], + 'response' => [ + 'httpCode' => $response->getStatusCode(), + 'body' => json_decode($respBody, true) + ], + 'responseTime' => round($elapsed * 1000) + ] + ); + + return $response; + } catch (ClientException $e) { + // On 401, regenerate token and retry once + if ($e->getResponse()->getStatusCode() === 401) { + Log::runtime()->warning( + [ + 'operation' => 'firebase_fcm_send_401', + 'message' => 'Token unauthorized, regenerating and retrying' + ] + ); + + $newToken = $this->getAccessToken(true); + + $retryRequest = new Request( + 'POST', + $url, + [ + 'Authorization' => 'Bearer ' . $newToken, + 'Content-Type' => 'application/json' + ], + json_encode(['message' => $message], JSON_THROW_ON_ERROR) + ); + + return $this->httpClient->send($retryRequest, $options); + } + + throw $e; + } + } + + /** + * Send to multiple tokens (multicast) + * + * @param array $tokens FCM registration tokens + * @param array $notification Notification payload + * @param array $data Data payload + * + * @return array Results with success/failure counts + */ + public function sendMulticast( + array $tokens, + array $notification = [], + array $data = [] + ): array { + $results = ['success' => 0, 'failure' => 0, 'responses' => []]; + + foreach ($tokens as $token) { + $message = ['token' => $token]; + if (!empty($notification)) { + $message['notification'] = $notification; + } + if (!empty($data)) { + $message['data'] = $data; + } + + try { + $response = $this->sendMessage($message); + if ($response->getStatusCode() === 200) { + $results['success']++; + $results['responses'][] = [ + 'token' => $token, + 'success' => true + ]; + } else { + $results['failure']++; + $results['responses'][] = [ + 'token' => $token, + 'success' => false, + 'error' => $response->getBody()->getContents() + ]; + } + } catch (\Throwable $e) { + $results['failure']++; + $results['responses'][] = [ + 'token' => $token, + 'success' => false, + 'error' => $e->getMessage() + ]; + } + } + + return $results; + } + + /** + * Generate JWT manually using OpenSSL + * + * @param array $payload JWT payload + * @param string $privateKey RSA private key + * + * @return string JWT token + */ + private function createJWT(array $payload, string $privateKey): string + { + $header = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + $payload = base64_encode(json_encode($payload)); + + $signature = ''; + openssl_sign( + $header . '.' . $payload, + $signature, + $privateKey, + OPENSSL_ALGO_SHA256 + ); + + $signature = base64_encode($signature); + + return str_replace(['+', '/', '='], ['-', '_', ''], $header . '.' . $payload . '.' . $signature); + } +} diff --git a/tests/Libraries/FirebaseClientTest.php b/tests/Libraries/FirebaseClientTest.php new file mode 100644 index 0000000..32927c2 --- /dev/null +++ b/tests/Libraries/FirebaseClientTest.php @@ -0,0 +1,291 @@ + + * @license https://mit-license.org/ MIT License + * @version GIT: 0.3.8 + * @link https://github.com/spotlibs + */ + +declare(strict_types=1); + +namespace Tests\Libraries; + +use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Psr7\Response; +use GuzzleHttp\Psr7\Request; +use Mockery; +use Spotlibs\PhpLib\Libraries\FirebaseClient; +use Tests\TestCase; + +// Test helper class that extends FirebaseClient +class TestableFirebaseClient extends FirebaseClient +{ + public function __construct(GuzzleClient $httpClient, array $serviceAccount) + { + $reflection = new \ReflectionClass(FirebaseClient::class); + + $httpProperty = $reflection->getProperty('httpClient'); + $httpProperty->setAccessible(true); + $httpProperty->setValue($this, $httpClient); + + $serviceProperty = $reflection->getProperty('serviceAccount'); + $serviceProperty->setAccessible(true); + $serviceProperty->setValue($this, $serviceAccount); + + $proxyProperty = $reflection->getProperty('proxyUrl'); + $proxyProperty->setAccessible(true); + $proxyProperty->setValue($this, ''); + + $tokenProperty = $reflection->getProperty('tokenFile'); + $tokenProperty->setAccessible(true); + $tokenProperty->setValue($this, '/mock/path/firebase_token.json'); + } + + // Expose getAccessToken as public for testing + public function getAccessTokenPublic(bool $forceRefresh = false): string + { + return 'mock_access_token_12345'; + } + + // Override sendMessage to use our public method + public function sendMessage(array $message): \Psr\Http\Message\ResponseInterface + { + $token = $this->getAccessTokenPublic(); + + $reflection = new \ReflectionClass(FirebaseClient::class); + $serviceProperty = $reflection->getProperty('serviceAccount'); + $serviceProperty->setAccessible(true); + $serviceAccount = $serviceProperty->getValue($this); + + $httpProperty = $reflection->getProperty('httpClient'); + $httpProperty->setAccessible(true); + $httpClient = $httpProperty->getValue($this); + + $proxyProperty = $reflection->getProperty('proxyUrl'); + $proxyProperty->setAccessible(true); + $proxyUrl = $proxyProperty->getValue($this); + + $startTime = microtime(true); + $projectId = $serviceAccount['project_id']; + $url = "https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send"; + + $request = new Request( + 'POST', + $url, + [ + 'Authorization' => 'Bearer ' . $token, + 'Content-Type' => 'application/json' + ], + json_encode(['message' => $message], JSON_THROW_ON_ERROR) + ); + + $options = []; + if (!empty($proxyUrl)) { + $options['proxy'] = $proxyUrl; + } + + try { + $response = $httpClient->send($request, $options); + return $response; + } catch (ClientException $e) { + if ($e->getResponse()->getStatusCode() === 401) { + $newToken = $this->getAccessTokenPublic(true); + + $retryRequest = new Request( + 'POST', + $url, + [ + 'Authorization' => 'Bearer ' . $newToken, + 'Content-Type' => 'application/json' + ], + json_encode(['message' => $message], JSON_THROW_ON_ERROR) + ); + + return $httpClient->send($retryRequest, $options); + } + + throw $e; + } + } +} + +class FirebaseClientTest extends TestCase +{ + private array $mockServiceAccount = [ + 'type' => 'service_account', + 'project_id' => 'test-project', + 'private_key_id' => 'key123', + 'private_key' => 'fake-key', + 'client_email' => 'test@test-project.iam.gserviceaccount.com', + 'client_id' => '12345', + ]; + + /** @test */ + public function testSendMessage(): void + { + $mockResponse = new Response( + 200, + [], + json_encode(['name' => 'projects/test-project/messages/123']) + ); + + $guzzleMock = Mockery::mock(GuzzleClient::class); + $guzzleMock->shouldReceive('send') + ->once() + ->andReturn($mockResponse); + + $client = $this->createMockedClient($guzzleMock); + + $message = [ + 'token' => 'device_token_123', + 'notification' => [ + 'title' => 'Test', + 'body' => 'Test message' + ] + ]; + + $response = $client->sendMessage($message); + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** @test */ + public function testSendMessageRetryOn401(): void + { + $unauthorizedResponse = new Response(401, [], json_encode(['error' => 'Unauthorized'])); + $successResponse = new Response(200, [], json_encode(['name' => 'projects/test-project/messages/123'])); + + $exception = new ClientException( + 'Unauthorized', + new Request('POST', 'https://fcm.googleapis.com'), + $unauthorizedResponse + ); + + $guzzleMock = Mockery::mock(GuzzleClient::class); + $guzzleMock->shouldReceive('send') + ->once() + ->andThrow($exception); + $guzzleMock->shouldReceive('send') + ->once() + ->andReturn($successResponse); + + $client = $this->createMockedClient($guzzleMock); + + $message = ['token' => 'device_token_123']; + $response = $client->sendMessage($message); + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** @test */ + public function testSendMulticast(): void + { + $mockResponse = new Response(200, [], json_encode(['name' => 'projects/test-project/messages/123'])); + + $guzzleMock = Mockery::mock(GuzzleClient::class); + $guzzleMock->shouldReceive('send') + ->times(3) + ->andReturn($mockResponse); + + $client = $this->createMockedClient($guzzleMock); + + $tokens = ['token1', 'token2', 'token3']; + $notification = ['title' => 'Test', 'body' => 'Message']; + + $result = $client->sendMulticast($tokens, $notification); + + $this->assertEquals(3, $result['success']); + $this->assertEquals(0, $result['failure']); + $this->assertCount(3, $result['responses']); + } + + /** @test */ + public function testSendMulticastWithFailures(): void + { + $successResponse = new Response(200, [], json_encode(['name' => 'projects/test-project/messages/123'])); + $errorResponse = new Response(400, [], json_encode(['error' => 'Invalid token'])); + + $guzzleMock = Mockery::mock(GuzzleClient::class); + $guzzleMock->shouldReceive('send') + ->once() + ->andReturn($successResponse); + $guzzleMock->shouldReceive('send') + ->once() + ->andReturn($errorResponse); + + $client = $this->createMockedClient($guzzleMock); + + $tokens = ['token1', 'token2']; + $result = $client->sendMulticast($tokens); + + $this->assertEquals(1, $result['success']); + $this->assertEquals(1, $result['failure']); + } + + /** @test */ + public function testSendMulticastWithException(): void + { + $mockResponse = new Response(200, [], json_encode(['name' => 'projects/test-project/messages/123'])); + + $guzzleMock = Mockery::mock(GuzzleClient::class); + $guzzleMock->shouldReceive('send') + ->once() + ->andReturn($mockResponse); + $guzzleMock->shouldReceive('send') + ->once() + ->andThrow(new \Exception('Network error')); + + $client = $this->createMockedClient($guzzleMock); + + $tokens = ['token1', 'token2']; + $result = $client->sendMulticast($tokens); + + $this->assertEquals(1, $result['success']); + $this->assertEquals(1, $result['failure']); + $this->assertStringContainsString('Network error', $result['responses'][1]['error']); + } + + /** @test */ + public function testSendMulticastWithData(): void + { + $mockResponse = new Response(200, [], json_encode(['name' => 'projects/test-project/messages/123'])); + + $guzzleMock = Mockery::mock(GuzzleClient::class); + $guzzleMock->shouldReceive('send') + ->once() + ->andReturn($mockResponse); + + $client = $this->createMockedClient($guzzleMock); + + $tokens = ['token1']; + $notification = ['title' => 'Test', 'body' => 'Message']; + $data = ['key' => 'value']; + + $result = $client->sendMulticast($tokens, $notification, $data); + + $this->assertEquals(1, $result['success']); + $this->assertEquals(0, $result['failure']); + } + + /** @test */ + public function testSetProxy(): void + { + $guzzleMock = Mockery::mock(GuzzleClient::class); + $client = $this->createMockedClient($guzzleMock); + + $result = $client->setProxy('http://proxy.example.com:8080'); + + $this->assertInstanceOf(TestableFirebaseClient::class, $result); + } + + private function createMockedClient($guzzleMock): TestableFirebaseClient + { + return new TestableFirebaseClient($guzzleMock, $this->mockServiceAccount); + } +} \ No newline at end of file