Initial V2 commit

This commit is contained in:
Hamza-Ayed
2026-04-22 21:59:56 +03:00
commit 4706404488
53 changed files with 4392 additions and 0 deletions

177
app/Services/FcmService.php Normal file
View 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;
}
}

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

View 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());
}
}
}