Deploy Ladill Woo Manager / deploy (push) Failing after 2s
Standalone app with SSO shell, WordPress plugin connect flow, webhook ingest, fulfillment inbox, and plugin activation API — no payment processing. Co-authored-by: Cursor <cursoragent@cursor.com>
152 lines
4.7 KiB
PHP
152 lines
4.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Notifications;
|
|
|
|
use Illuminate\Http\Client\Response;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class FcmService
|
|
{
|
|
public const DELIVERED = 'ok';
|
|
|
|
public const INVALID_TOKEN = 'invalid_token';
|
|
|
|
public const FAILED = 'failed';
|
|
|
|
public function isConfigured(): bool
|
|
{
|
|
return filled(config('services.fcm.project_id'))
|
|
&& filled(config('services.fcm.service_account_json'));
|
|
}
|
|
|
|
/**
|
|
* @return self::DELIVERED|self::INVALID_TOKEN|self::FAILED
|
|
*/
|
|
public function send(string $fcmToken, string $title, string $body, array $data = []): string
|
|
{
|
|
if (! $this->isConfigured()) {
|
|
return self::FAILED;
|
|
}
|
|
|
|
$projectId = (string) config('services.fcm.project_id');
|
|
$accessToken = $this->accessToken();
|
|
|
|
$response = Http::withToken($accessToken)
|
|
->timeout(15)
|
|
->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", [
|
|
'message' => [
|
|
'token' => $fcmToken,
|
|
'notification' => compact('title', 'body'),
|
|
'data' => array_map('strval', $data),
|
|
'android' => [
|
|
'priority' => 'high',
|
|
'notification' => [
|
|
'sound' => 'default',
|
|
'channel_id' => 'payments',
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
return self::DELIVERED;
|
|
}
|
|
|
|
$outcome = $this->classifyFailure($response);
|
|
|
|
Log::warning('FCM push failed', [
|
|
'token' => substr($fcmToken, 0, 20).'…',
|
|
'status' => $response->status(),
|
|
'outcome' => $outcome,
|
|
'body' => $response->body(),
|
|
]);
|
|
|
|
return $outcome;
|
|
}
|
|
|
|
/**
|
|
* @return self::INVALID_TOKEN|self::FAILED
|
|
*/
|
|
private function classifyFailure(Response $response): string
|
|
{
|
|
$statusCode = $response->status();
|
|
$body = strtolower((string) $response->body());
|
|
$apiStatus = strtoupper((string) data_get($response->json(), 'error.status', ''));
|
|
|
|
if ($statusCode === 404
|
|
|| $apiStatus === 'NOT_FOUND'
|
|
|| str_contains($body, 'not_found')
|
|
|| str_contains($body, 'requested entity was not found')
|
|
|| str_contains($body, 'registration token is not a valid')
|
|
|| str_contains($body, 'invalid registration')
|
|
|| str_contains($body, 'unregistered')) {
|
|
return self::INVALID_TOKEN;
|
|
}
|
|
|
|
return self::FAILED;
|
|
}
|
|
|
|
private function accessToken(): string
|
|
{
|
|
return Cache::remember('mini_fcm_access_token', 3300, function (): string {
|
|
$sa = $this->serviceAccount();
|
|
$jwt = $this->buildJwt($sa);
|
|
|
|
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
|
|
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
'assertion' => $jwt,
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
throw new \RuntimeException('FCM OAuth2 token exchange failed: '.$response->body());
|
|
}
|
|
|
|
return $response->json('access_token');
|
|
});
|
|
}
|
|
|
|
private function buildJwt(array $sa): string
|
|
{
|
|
$header = $this->base64url(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
|
|
$now = time();
|
|
$payload = $this->base64url(json_encode([
|
|
'iss' => $sa['client_email'],
|
|
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
|
|
'aud' => 'https://oauth2.googleapis.com/token',
|
|
'iat' => $now,
|
|
'exp' => $now + 3600,
|
|
]));
|
|
|
|
$message = "{$header}.{$payload}";
|
|
openssl_sign($message, $signature, $sa['private_key'], 'sha256WithRSAEncryption');
|
|
|
|
return "{$message}.{$this->base64url($signature)}";
|
|
}
|
|
|
|
private function base64url(string $data): string
|
|
{
|
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function serviceAccount(): array
|
|
{
|
|
$value = config('services.fcm.service_account_json');
|
|
|
|
if (blank($value)) {
|
|
throw new \RuntimeException('FCM service account JSON is not configured.');
|
|
}
|
|
|
|
$json = is_file($value) ? file_get_contents($value) : $value;
|
|
$decoded = json_decode((string) $json, true);
|
|
|
|
if (! is_array($decoded) || empty($decoded['private_key'])) {
|
|
throw new \RuntimeException('FCM service account JSON is invalid or missing private_key.');
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
}
|