Initial V2 commit
This commit is contained in:
177
app/Services/FcmService.php
Normal file
177
app/Services/FcmService.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* FCM Notification Service
|
||||
*
|
||||
* Sends push notifications via Firebase Cloud Messaging (HTTP v1 API).
|
||||
* Replaces the scattered sendFCM_Internal() calls from V1.
|
||||
*/
|
||||
class FcmService
|
||||
{
|
||||
private ?string $accessToken = null;
|
||||
private string $credentialsPath;
|
||||
private string $cachePath;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->credentialsPath = config('intaleq.fcm_credentials_path');
|
||||
$this->cachePath = config('intaleq.fcm_cache_path');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send FCM notification to a specific device token
|
||||
*/
|
||||
public function sendToDevice(string $token, string $title, string $body, array $data = [], string $category = ''): array
|
||||
{
|
||||
if (empty($token)) {
|
||||
return ['status' => 'error', 'message' => 'Empty token'];
|
||||
}
|
||||
|
||||
// Add category to data payload
|
||||
if ($category) {
|
||||
$data['category'] = $category;
|
||||
}
|
||||
|
||||
// Convert non-string data values
|
||||
$stringData = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$stringData[$key] = is_array($value) ? json_encode($value) : (string) $value;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'message' => [
|
||||
'token' => $token,
|
||||
'notification' => [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
],
|
||||
'data' => $stringData,
|
||||
'android' => [
|
||||
'priority' => 'high',
|
||||
],
|
||||
'apns' => [
|
||||
'payload' => [
|
||||
'aps' => [
|
||||
'sound' => 'default',
|
||||
'badge' => 1,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
return $this->sendRequest($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send to FCM topic
|
||||
*/
|
||||
public function sendToTopic(string $topic, string $title, string $body, array $data = []): array
|
||||
{
|
||||
$payload = [
|
||||
'message' => [
|
||||
'topic' => $topic,
|
||||
'notification' => [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
],
|
||||
'data' => array_map('strval', $data),
|
||||
],
|
||||
];
|
||||
|
||||
return $this->sendRequest($payload);
|
||||
}
|
||||
|
||||
private function sendRequest(array $payload): array
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (!$accessToken) {
|
||||
return ['status' => 'error', 'message' => 'Failed to get access token'];
|
||||
}
|
||||
|
||||
$credentials = json_decode(file_get_contents($this->credentialsPath), true);
|
||||
$projectId = $credentials['project_id'] ?? '';
|
||||
$url = "https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send";
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: Bearer {$accessToken}",
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200) {
|
||||
return ['status' => 'success', 'response' => json_decode($result, true)];
|
||||
}
|
||||
|
||||
Log::error("[FCM] Error {$httpCode}: {$result}");
|
||||
return ['status' => 'error', 'code' => $httpCode, 'response' => $result];
|
||||
}
|
||||
|
||||
private function getAccessToken(): ?string
|
||||
{
|
||||
// Check cache
|
||||
if (file_exists($this->cachePath)) {
|
||||
$cached = json_decode(file_get_contents($this->cachePath), true);
|
||||
if ($cached && ($cached['expires_at'] ?? 0) > time() + 60) {
|
||||
return $cached['token'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!file_exists($this->credentialsPath)) return null;
|
||||
|
||||
$credentials = json_decode(file_get_contents($this->credentialsPath), true);
|
||||
$clientEmail = $credentials['client_email'];
|
||||
$privateKey = $credentials['private_key'];
|
||||
|
||||
$now = time();
|
||||
$header = rtrim(strtr(base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), '+/', '-_'), '=');
|
||||
$claim = rtrim(strtr(base64_encode(json_encode([
|
||||
'iss' => $clientEmail,
|
||||
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now,
|
||||
])), '+/', '-_'), '=');
|
||||
|
||||
$signature = '';
|
||||
openssl_sign("{$header}.{$claim}", $signature, $privateKey, 'SHA256');
|
||||
$jwt = "{$header}.{$claim}." . rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
|
||||
|
||||
$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,
|
||||
]);
|
||||
$res = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$token = json_decode($res, true)['access_token'] ?? null;
|
||||
|
||||
if ($token) {
|
||||
file_put_contents($this->cachePath, json_encode([
|
||||
'token' => $token,
|
||||
'expires_at' => time() + 3500,
|
||||
]));
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
104
app/Services/PayloadCrypto.php
Normal file
104
app/Services/PayloadCrypto.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Payload Crypto Service — AES-256-GCM
|
||||
*
|
||||
* Dynamic encryption for all payloads between Flutter apps and the API.
|
||||
* Unlike LegacyEncryption which uses static IV, this generates a unique IV per request.
|
||||
*
|
||||
* Format: base64(IV + ciphertext + tag)
|
||||
* - IV: 12 bytes (random per encryption)
|
||||
* - Tag: 16 bytes (integrity verification)
|
||||
*/
|
||||
class PayloadCrypto
|
||||
{
|
||||
private string $key;
|
||||
private const CIPHER = 'aes-256-gcm';
|
||||
private const IV_LENGTH = 12;
|
||||
private const TAG_LENGTH = 16;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$keyPath = config('intaleq.legacy_enc_key_path');
|
||||
if (!file_exists($keyPath)) {
|
||||
throw new \RuntimeException('Encryption key not found');
|
||||
}
|
||||
// Derive a 32-byte key from the stored key using HKDF
|
||||
$rawKey = trim(file_get_contents($keyPath));
|
||||
$this->key = hash_hkdf('sha256', $rawKey, 32, 'intaleq-v2-gcm');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt payload for sending to Flutter app
|
||||
*
|
||||
* @param array|string $data Data to encrypt
|
||||
* @return string Base64 encoded (IV + ciphertext + tag)
|
||||
*/
|
||||
public function encrypt($data): string
|
||||
{
|
||||
$plaintext = is_array($data) ? json_encode($data) : $data;
|
||||
$iv = random_bytes(self::IV_LENGTH);
|
||||
$tag = '';
|
||||
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plaintext,
|
||||
self::CIPHER,
|
||||
$this->key,
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag,
|
||||
'', // Additional Authenticated Data (AAD)
|
||||
self::TAG_LENGTH
|
||||
);
|
||||
|
||||
if ($ciphertext === false) {
|
||||
throw new \RuntimeException('Encryption failed');
|
||||
}
|
||||
|
||||
// Pack: IV (12) + ciphertext (variable) + tag (16)
|
||||
return base64_encode($iv . $ciphertext . $tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt payload received from Flutter app
|
||||
*
|
||||
* @param string $encoded Base64 encoded (IV + ciphertext + tag)
|
||||
* @return string|null Decrypted plaintext or null on failure
|
||||
*/
|
||||
public function decrypt(string $encoded): ?string
|
||||
{
|
||||
$raw = base64_decode($encoded, true);
|
||||
if ($raw === false || strlen($raw) < self::IV_LENGTH + self::TAG_LENGTH + 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$iv = substr($raw, 0, self::IV_LENGTH);
|
||||
$tag = substr($raw, -self::TAG_LENGTH);
|
||||
$ciphertext = substr($raw, self::IV_LENGTH, -self::TAG_LENGTH);
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
$ciphertext,
|
||||
self::CIPHER,
|
||||
$this->key,
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag
|
||||
);
|
||||
|
||||
return $plaintext !== false ? $plaintext : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt and decode JSON payload
|
||||
*/
|
||||
public function decryptJson(string $encoded): ?array
|
||||
{
|
||||
$plaintext = $this->decrypt($encoded);
|
||||
if (!$plaintext) return null;
|
||||
|
||||
$data = json_decode($plaintext, true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
}
|
||||
74
app/Services/SocketService.php
Normal file
74
app/Services/SocketService.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Socket Communication Service
|
||||
*
|
||||
* Communicates with Node.js socket servers for real-time events.
|
||||
* Replaces hardcoded IPs in V1 with .env configuration.
|
||||
*/
|
||||
class SocketService
|
||||
{
|
||||
private string $locationServerUrl;
|
||||
private string $rideSocketUrl;
|
||||
private string $internalKey;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->locationServerUrl = config('intaleq.location_server_url');
|
||||
$this->rideSocketUrl = config('intaleq.ride_socket_url');
|
||||
|
||||
$keyPath = config('intaleq.internal_socket_key_path');
|
||||
$this->internalKey = file_exists($keyPath) ? trim(file_get_contents($keyPath)) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify passenger via ride socket server
|
||||
*/
|
||||
public function notifyPassenger(string $passengerId, array $payload): void
|
||||
{
|
||||
$this->sendAsync($this->rideSocketUrl, array_merge($payload, [
|
||||
'action' => 'notify_passenger',
|
||||
'passenger_id' => $passengerId,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send event to location server (e.g., ride_taken, cancel_ride)
|
||||
*/
|
||||
public function sendToLocationServer(string $event, array $data): void
|
||||
{
|
||||
$this->sendAsync($this->locationServerUrl, array_merge($data, [
|
||||
'action' => $event,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-blocking HTTP POST (fire and forget with short timeout)
|
||||
*/
|
||||
private function sendAsync(string $url, array $data): void
|
||||
{
|
||||
try {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($data),
|
||||
CURLOPT_TIMEOUT_MS => 500,
|
||||
CURLOPT_NOSIGNAL => 1,
|
||||
]);
|
||||
|
||||
if ($this->internalKey) {
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ["x-internal-key: {$this->internalKey}"]);
|
||||
}
|
||||
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning("[Socket] Failed to send to {$url}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user