Initial commit with updated Auth and media ignored

This commit is contained in:
Hamza-Ayed
2026-04-28 13:04:27 +03:00
commit 67af97474c
477 changed files with 66444 additions and 0 deletions

239
core/Auth/JwtService.php Normal file
View File

@@ -0,0 +1,239 @@
<?php
// ============================================================
// core/Auth/JwtService.php
// JWT آمن: JTI + Blacklist في Redis + Refresh Token
// ============================================================
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\BeforeValidException;
class JwtService
{
private string $secretKey;
private string $hmacSecret;
private string $fpPepper;
private ?Redis $redis;
private string $issuer;
private const ACCESS_TTL = 3600; // 1 ساعة
private const REFRESH_TTL = 2592000; // 30 يوم
private const ALGO = 'HS256';
// Endpoints مسموح لها بتوكن نوع registration
private const REGISTRATION_ENDPOINTS = [
'loginFirstTime', 'loginFirstTimeDriver',
'checkPhoneNumberISVerfiedDriver', 'checkPhoneNumberISVerfiedPassenger',
'otpmessage', 'signup', 'verifyEmail', 'verifyOtpMessage',
'sendVerifyEmail', 'sendWhatsAppDriver', 'register_passenger',
'sendWhatsOpt', 'verifyOtp', 'auth_proxy', 'addToken',
'loginFromGoogle', 'loginUsingCredentialsWithoutGoogle',
'register', 'sendOtpMessageDriver', 'getTokensPassenger',
'send_otp', 'verify_otp', 'errorApp', 'register_driver',
];
public function __construct(?Redis $redis = null)
{
$this->secretKey = trim(file_get_contents('/home/intaleq-api/.secret_key'));
$this->hmacSecret = getenv('SECRET_KEY_HMAC') ?: '';
$this->fpPepper = getenv('FP_PEPPER') ?: '';
$this->issuer = getenv('APP_ISSUER') ;
$this->redis = $redis;
}
// ── توليد Access Token ──────────────────────────────────
public function generateAccessToken(
int|string $userId,
string $role,
string $audience,
?string $fingerprint = null
): string {
$jti = bin2hex(random_bytes(16));
$ttl = 3600;
if ($role === 'driver') {
$ttl = 14400;
} elseif ($role === 'passenger') {
$ttl = 3600;
}
$payload = [
'iss' => $this->issuer,
'aud' => $audience,
'user_id' => $userId,
'role' => $role,
'token_type' => 'access',
'jti' => $jti,
'iat' => time(),
'exp' => time() + $ttl,
];
if ($fingerprint && $this->fpPepper) {
$payload['fingerPrint'] = hash('sha256', $fingerprint . $this->fpPepper);
}
return JWT::encode($payload, $this->secretKey, self::ALGO);
}
// ── توليد Refresh Token ─────────────────────────────────
public function generateRefreshToken(int|string $userId): array
{
$token = bin2hex(random_bytes(32));
$exp = time() + self::REFRESH_TTL;
// تخزين في Redis
if ($this->redis) {
$this->redis->setex(
"refresh:{$userId}:{$token}",
self::REFRESH_TTL,
json_encode(['user_id' => $userId, 'created_at' => time()])
);
}
return ['token' => $token, 'expires_at' => $exp];
}
// ── التحقق الكامل من التوكن ────────────────────────────
public function authenticate(): object
{
// 1. استخراج التوكن
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$token = null;
if (preg_match('/Bearer\s(\S+)/', $authHeader, $m)) {
$token = $m[1];
}
if (!$token) {
self::abort(401, 'Authorization token required');
}
// 2. Decode
try {
$decoded = JWT::decode($token, new Key($this->secretKey, self::ALGO));
} catch (ExpiredException $e) {
self::abort(401, 'Token expired');
} catch (SignatureInvalidException $e) {
// محاولة فك التشفير بمفتاح المحفظة (Wallet secret fallback)
$payKeyPath = '/home/intaleq-api/.secret_key_pay';
$payKey = file_exists($payKeyPath) ? trim(file_get_contents($payKeyPath)) : '';
if ($payKey) {
try {
$decoded = JWT::decode($token, new Key($payKey, self::ALGO));
} catch (Exception $e2) {
self::abort(401, 'Invalid token signature');
}
} else {
self::abort(401, 'Invalid token signature');
}
} catch (BeforeValidException $e) {
self::abort(401, 'Token not yet valid');
} catch (Exception $e) {
self::abort(401, 'Invalid token');
}
// 3. Issuer
if (($decoded->iss ?? '') !== $this->issuer) {
self::abort(401, 'Invalid token issuer');
}
// 4. User ID
$userId = $decoded->user_id ?? $decoded->sub ?? null;
if (!$userId) {
self::abort(401, 'Invalid JWT payload');
}
// 5. JTI Blacklist (تحقق من توكنات ملغاة)
$jti = $decoded->jti ?? null;
if ($jti && $this->redis) {
if ($this->redis->exists("jwt:blacklist:$jti")) {
self::abort(401, 'Token has been revoked');
}
}
// 6. token_type — قيّد registration endpoints
$tokenType = $decoded->token_type ?? 'access';
if ($tokenType === 'registration' || $tokenType === 'new') {
$currentFile = basename($_SERVER['PHP_SELF'], '.php');
$allowed = false;
foreach (self::REGISTRATION_ENDPOINTS as $ep) {
if (strcasecmp($currentFile, $ep) === 0) {
$allowed = true;
break;
}
}
if (!$allowed) {
error_log("[SECURITY] Registration token blocked on: $currentFile | user: $userId");
self::abort(403, 'Token not authorized for this action');
}
}
// 7. Device Fingerprint (إلزامي للـ Access Tokens)
if ($this->fpPepper && $tokenType === 'access') {
$fpInToken = $decoded->fingerPrint ?? null;
$fpHeader = $_SERVER['HTTP_X_DEVICE_FP'] ?? null;
if ($fpInToken === null || $fpHeader === null) {
error_log("[SECURITY] Fingerprint missing | user: $userId");
self::abort(403, 'Device verification required');
}
$expected = hash('sha256', $fpHeader . $this->fpPepper);
if (!hash_equals($expected, $fpInToken)) {
error_log("[SECURITY] Device mismatch | user: $userId | IP: " . ($_SERVER['REMOTE_ADDR'] ?? '?'));
self::abort(403, 'Device mismatch');
}
}
// 8. HMAC — مطلوب للعمليات الحساسة (Wallet/Logout)
$hmacHeader = $_SERVER['HTTP_X_HMAC_AUTH'] ?? null;
if ($hmacHeader !== null) {
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$nonce = $_SERVER['HTTP_X_NONCE'] ?? '';
$body = file_get_contents('php://input') ?: '';
// التوقيع يضم الـ Body + Timestamp + Nonce لمنع التكرار والتلاعب
$payloadToSign = $body . $timestamp . $nonce;
$expectedHmac = hash_hmac('sha256', $payloadToSign, $this->hmacSecret);
if (!hash_equals($expectedHmac, $hmacHeader)) {
error_log("[SECURITY] HMAC mismatch | user: $userId | IP: " . ($_SERVER['REMOTE_ADDR'] ?? '?'));
self::abort(403, 'Invalid HMAC signature');
}
}
return $decoded;
}
// ── إلغاء توكن (Logout / Password Change) ──────────────
public function revokeToken(string $jti, int $remainingTTL = 900): void
{
if ($this->redis && $jti) {
$this->redis->setex("jwt:blacklist:$jti", $remainingTTL + 60, '1');
}
}
// ── Internal API Key — للـ get_connect.php ─────────────
public static function validateInternalKey(): void
{
$keyPath = '/home/intaleq-api/.internal_socket_key';
$sent = $_SERVER['HTTP_X_INTERNAL_KEY'] ?? '';
$expected = (file_exists($keyPath) ? trim(file_get_contents($keyPath)) : '') ?: 'Intaleq_Secure_Bridge_Key_2026_@!socket';
if (!$expected || !hash_equals($expected, $sent)) {
error_log('[SECURITY] Invalid internal key from: ' . ($_SERVER['REMOTE_ADDR'] ?? '?'));
http_response_code(403);
echo json_encode(['error' => 'Unauthorized internal request']);
exit;
}
}
private static function abort(int $code, string $message): never
{
http_response_code($code);
echo json_encode(['error' => $message]);
exit;
}
}

82
core/Auth/RateLimiter.php Normal file
View File

@@ -0,0 +1,82 @@
<?php
// ============================================================
// core/Auth/RateLimiter.php
// Sliding Window Rate Limiting باستخدام Redis
// ============================================================
class RateLimiter
{
private ?Redis $redis;
// حدود مختلفة لكل نوع endpoint
private const LIMITS = [
'login' => ['requests' => 5, 'window' => 60], // 5 محاولات / دقيقة
'otp' => ['requests' => 3, 'window' => 300], // 3 محاولات / 5 دقائق
'register' => ['requests' => 3, 'window' => 3600], // 3 محاولات / ساعة
'api' => ['requests' => 120, 'window' => 60], // 120 طلب / دقيقة
'ride' => ['requests' => 30, 'window' => 60], // 30 طلب / دقيقة
'upload' => ['requests' => 10, 'window' => 300], // 10 رفع / 5 دقائق
];
public function __construct(?Redis $redis)
{
$this->redis = $redis;
}
// ── فحص الحد ─────────────────────────────────────────────
// $identifier: IP:userId أو IP فقط
// $type: login | otp | api | ride | upload
public function check(string $identifier, string $type = 'api'): bool
{
if (!$this->redis) {
return true; // بدون Redis نمرر (fallback)
}
$limit = self::LIMITS[$type] ?? self::LIMITS['api'];
$window = $limit['window'];
$max = $limit['requests'];
$key = "rate:{$type}:{$identifier}";
$current = $this->redis->incr($key);
if ($current === 1) {
$this->redis->expire($key, $window);
}
return $current <= $max;
}
// ── تطبيق الحد وإيقاف الطلب إن تجاوز ─────────────────────
public function enforce(string $identifier, string $type = 'api'): void
{
if (!$this->check($identifier, $type)) {
$limit = self::LIMITS[$type] ?? self::LIMITS['api'];
$window = $limit['window'];
error_log("[RATE_LIMIT] Blocked: $identifier | type: $type");
http_response_code(429);
header("Retry-After: $window");
echo json_encode([
'error' => 'Too many requests. Please slow down.',
'retry_after' => $window,
]);
exit;
}
}
// ── بناء معرّف المستخدم ────────────────────────────────────
public static function identifier(?string $userId = null): string
{
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
return $userId ? "{$ip}:{$userId}" : $ip;
}
// ── إعادة تعيين عداد (مثلاً بعد تسجيل دخول ناجح) ───────────
public function reset(string $identifier, string $type = 'login'): void
{
if ($this->redis) {
$this->redis->del("rate:{$type}:{$identifier}");
}
}
}

View File

@@ -0,0 +1,79 @@
<?php
// ============================================================
// core/Database/Database.php — Lazy PDO Singleton (Refactored)
// يدعم قواعد بيانات متعددة لكل منها Host/User/Pass مختلف
// ============================================================
class Database
{
private static array $instances = [];
// خريطة الربط مع متغيرات البيئة (ENV)
private static array $map = [
'main' => [
'name' => 'DB_PRIMARY_NAME_V2',
'host' => 'DB_PRIMARY_HOST_V2',
'user' => 'DB_PRIMARY_USER_V2',
'pass' => 'DB_PRIMARY_PASS_V2',
],
'tracking' => [
'name' => 'dbname_track',
'host' => 'DB_TRACKING_HOST',
'user' => 'DB_TRACKING_USER',
'pass' => 'DB_TRACKING_PASS',
],
'ride' => [
'name' => 'dbname_ride',
'host' => 'DB_RIDE_HOST',
'user' => 'DB_RIDE_USER',
'pass' => 'DB_RIDE_PASS',
],
];
public static function get(string $name = 'main'): PDO
{
if (!isset(self::$instances[$name])) {
self::$instances[$name] = self::connect($name);
}
return self::$instances[$name];
}
private static function connect(string $name): PDO
{
if (!isset(self::$map[$name])) {
throw new InvalidArgumentException("Unknown database: $name");
}
$cfg = self::$map[$name];
$dbname = getenv($cfg['name']);
$host = getenv($cfg['host']) ?: 'localhost';
$user = getenv($cfg['user']);
$pass = getenv($cfg['pass']);
if (!$dbname || !$user) {
error_log("[FATAL] Database config missing for: $name (Check ENV keys: {$cfg['name']}, {$cfg['user']})");
throw new RuntimeException("Database configuration error.");
}
$dsn = "mysql:host=$host;dbname=$dbname;charset=utf8mb4";
$options = [
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_PERSISTENT => true,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci",
PDO::ATTR_TIMEOUT => 10,
];
try {
return new PDO($dsn, $user, $pass, $options);
} catch (PDOException $e) {
error_log("[DB] Connection failed ($name) at $host: " . $e->getMessage());
throw $e;
}
}
private function __construct() {}
private function __clone() {}
}

View File

@@ -0,0 +1,88 @@
<?php
// ============================================================
// core/Security/EncryptionHelper.php
// يدعم AES-256-GCM الجديد + AES-256-CBC القديم (توافقية)
// ============================================================
class EncryptionHelper
{
private string $key;
private string $cbcIv;
private const ALGO_GCM = 'aes-256-gcm';
private const ALGO_CBC = 'AES-256-CBC'; // للتوافقية
private const IV_LEN_GCM = 12;
private const TAG_LEN = 16;
private const PREFIX_GCM = 'GCM:'; // للتمييز بين الجديد والقديم
public function __construct(string $key, ?string $cbcIv = null)
{
if (strlen($key) !== 32) {
throw new InvalidArgumentException('Encryption key must be exactly 32 bytes.');
}
$this->key = $key;
// IV القديم للتوافقية أثناء مرحلة المايغريشن
$this->cbcIv = $cbcIv ?: getenv('initializationVector') ?: str_repeat('0', 16);
}
// ─── تشفير نص (CBC مؤقتاً للتوافق التام كما طلب المستخدم) ──
// سيتم تغييره لاحقاً لـ GCM بعد تفريغ قاعدة البيانات القديمة
public function encryptData(string $plainText): string
{
// بناءً على طلب المستخدم: إبقاء التشفير الحالي CBC حتى نقوم بالترحيل لاحقاً
$plainText = mb_convert_encoding($plainText, 'UTF-8');
$paddedText = $this->addPadding($plainText);
$encrypted = openssl_encrypt($paddedText, self::ALGO_CBC, $this->key, OPENSSL_RAW_DATA, $this->cbcIv);
return base64_encode($encrypted);
}
// ─── فك تشفير نص (يدعم CBC والـ GCM المستقبلي) ───────────
public function decryptData(string $cipherText): string|false
{
// تحقق إن كان مشفر بالنظام الجديد
if (str_starts_with($cipherText, self::PREFIX_GCM)) {
$raw = base64_decode(substr($cipherText, strlen(self::PREFIX_GCM)), true);
if ($raw === false || strlen($raw) < self::IV_LEN_GCM + self::TAG_LEN) return false;
$iv = substr($raw, 0, self::IV_LEN_GCM);
$tag = substr($raw, self::IV_LEN_GCM, self::TAG_LEN);
$cipher = substr($raw, self::IV_LEN_GCM + self::TAG_LEN);
$plain = openssl_decrypt($cipher, self::ALGO_GCM, $this->key, OPENSSL_RAW_DATA, $iv, $tag);
return $plain !== false ? $plain : false;
}
// وإلا استخدم CBC القديم
$decoded = base64_decode($cipherText, true);
if ($decoded === false) return false;
$decrypted = openssl_decrypt($decoded, self::ALGO_CBC, $this->key, OPENSSL_RAW_DATA, $this->cbcIv);
if ($decrypted === false) return false;
$pad = ord($decrypted[strlen($decrypted) - 1]);
if ($pad < 1 || $pad > 16) return false;
return substr($decrypted, 0, -$pad);
}
// ─── تشفير/فك تشفير Binary (صور، ملفات) ───────────────
public function encryptBinary(string $data): string
{
return openssl_encrypt($data, self::ALGO_CBC, $this->key, OPENSSL_RAW_DATA, $this->cbcIv);
}
public function decryptBinary(string $data): string|false
{
return openssl_decrypt($data, self::ALGO_CBC, $this->key, OPENSSL_RAW_DATA, $this->cbcIv);
}
// --------- دوال الـ Padding للـ CBC ----------
private function addPadding($data, $blockSize = 16) {
$pad = $blockSize - (strlen($data) % $blockSize);
return $data . str_repeat(chr($pad), $pad);
}
private function removePadding($data) {
$pad = ord($data[strlen($data) - 1]);
return substr($data, 0, -$pad);
}
}

View File

@@ -0,0 +1,156 @@
<?php
// ============================================================
// core/Services/FcmService.php
// إرسال FCM مع كاش توكن في Redis (بدل ملف)
// ============================================================
class FcmService
{
private ?Redis $redis;
private string $serviceAccountFile;
public function __construct(?Redis $redis = null)
{
$this->redis = $redis;
// المسار بناء على بنية المشروع
$this->serviceAccountFile = __DIR__ . '/../../service-account.json';
}
// ── إرسال إشعار ────────────────────────────────────────
public function send(
string $token,
string $title,
string $body,
array $data = [],
string $category = 'Order',
string $tone = 'ding'
): array {
$accessToken = $this->getAccessToken();
if (!$accessToken) {
return ['status' => 'error', 'message' => 'No access token'];
}
if (!file_exists($this->serviceAccountFile)) {
return ['status' => 'error', 'message' => 'Service account file missing'];
}
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
$projectId = $creds['project_id'];
$fcmUrl = "https://fcm.googleapis.com/v1/projects/$projectId/messages:send";
$finalData = array_merge($data, [
'title' => $title,
'body' => $body,
'tone' => $tone,
'category' => $category,
'type' => $category,
]);
// FCM يشترط أن تكون كل القيم strings
$processedData = array_map(
fn($v) => is_array($v) || is_object($v)
? json_encode($v, JSON_UNESCAPED_UNICODE)
: (string)$v,
$finalData
);
$payload = [
'message' => [
'token' => $token,
'data' => $processedData,
'android' => ['priority' => 'HIGH'],
'apns' => [
'headers' => ['apns-priority' => '10', 'apns-push-type' => 'background'],
'payload' => ['aps' => ['content-available' => 1]],
],
],
];
$ch = curl_init($fcmUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $accessToken",
'Content-Type: application/json; charset=UTF-8',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_FRESH_CONNECT => false, // إعادة استخدام الاتصال
CURLOPT_FORBID_REUSE => false,
CURLOPT_TCP_KEEPALIVE => 1,
]);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_errno($ch);
curl_close($ch);
if ($curlErr) {
return ['status' => 'error', 'message' => 'CURL error'];
}
return $httpCode === 200
? ['status' => 'success']
: ['status' => 'error', 'code' => $httpCode, 'response' => $result];
}
// ── Access Token مع Redis Cache ─────────────────────────
private function getAccessToken(): ?string
{
// 1. من Redis
if ($this->redis) {
$cached = $this->redis->get('google_fcm_access_token');
if ($cached) return $cached;
}
// 2. طلب جديد
$token = $this->fetchGoogleToken();
if ($token && $this->redis) {
$this->redis->setex('google_fcm_access_token', 3500, $token);
}
return $token;
}
private function fetchGoogleToken(): ?string
{
if (!file_exists($this->serviceAccountFile)) return null;
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
$clientEmail = $creds['client_email'];
$privateKey = $creds['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,
CURLOPT_TIMEOUT => 10,
]);
$res = curl_exec($ch);
curl_close($ch);
return json_decode($res, true)['access_token'] ?? null;
}
}

View File

@@ -0,0 +1,77 @@
<?php
// ============================================================
// core/Services/OtpService.php
// تخزين OTP في Redis بدلاً من MySQL (أسرع وأخف)
// ============================================================
class OtpService
{
private ?Redis $redis;
private const OTP_TTL = 300; // 5 دقائق
private const MAX_ATTEMPTS = 3;
private const LOCKOUT_TTL = 1800; // 30 دقيقة إذا تجاوز المحاولات
public function __construct(?Redis $redis)
{
$this->redis = $redis;
}
// ── توليد وحفظ OTP ─────────────────────────────────────
public function generate(string $phone): string
{
// OTP آمن (6 أرقام عشوائية)
$otp = str_pad((string)random_int(100000, 999999), 6, '0', STR_PAD_LEFT);
if ($this->redis) {
$key = "otp:{$phone}";
$this->redis->setex($key, self::OTP_TTL, password_hash($otp, PASSWORD_BCRYPT));
// إعادة تعيين عداد المحاولات
$this->redis->del("otp:attempts:{$phone}");
}
return $otp;
}
// ── التحقق من OTP ───────────────────────────────────────
public function verify(string $phone, string $inputOtp): bool
{
if (!$this->redis) return false;
// فحص الـ lockout
if ($this->redis->exists("otp:locked:{$phone}")) {
return false;
}
$key = "otp:{$phone}";
$stored = $this->redis->get($key);
if (!$stored) {
return false; // انتهت صلاحية الـ OTP
}
$attemptsKey = "otp:attempts:{$phone}";
if (!password_verify($inputOtp, $stored)) {
$attempts = $this->redis->incr($attemptsKey);
$this->redis->expire($attemptsKey, self::OTP_TTL);
if ($attempts >= self::MAX_ATTEMPTS) {
// قفل لمدة 30 دقيقة
$this->redis->setex("otp:locked:{$phone}", self::LOCKOUT_TTL, '1');
$this->redis->del($key);
}
return false;
}
// نجح التحقق — احذف الـ OTP
$this->redis->del($key);
$this->redis->del($attemptsKey);
return true;
}
// ── فحص هل الرقم مقفل ──────────────────────────────────
public function isLocked(string $phone): bool
{
return $this->redis && (bool)$this->redis->exists("otp:locked:{$phone}");
}
}

90
core/bootstrap.php Normal file
View File

@@ -0,0 +1,90 @@
<?php
// ============================================================
// core/bootstrap.php
// البوابة الرئيسية الموحدة لكل التطبيق
// ============================================================
declare(strict_types=1);
// 1. إعدادات الأخطاء والـ Headers الأساسية
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
// تحديد مسار اللوج بشكل ديناميكي (محلياً أو سيرفر)
$logPath = '/home/intaleq-api/logs/php_errors.log';
if (!file_exists(dirname($logPath)) || !is_writable(dirname($logPath))) {
$logPath = __DIR__ . '/../logs/php_errors.log';
}
ini_set('error_log', $logPath);
header('Content-Type: application/json; charset=UTF-8');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
// CORS (يجب تخصيصه في endpoints مخصصة إن لزم، لكن هذا افتراضي)
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Device-FP, X-HMAC-Auth, X-Internal-Key');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
// 2. Autoload
$vendorPath = realpath(__DIR__ . '/../../vendor/autoload.php');
if ($vendorPath) require_once $vendorPath;
// 3. Helpers & Env
require_once __DIR__ . '/helpers.php';
// تحديد مسار الـ .env بشكل ديناميكي
$envFile = '/home/intaleq-api/env/.env';
if (!file_exists($envFile)) {
$envFile = __DIR__ . '/../.env'; // مسار محلي افتراضي
}
loadEnvironment($envFile);
// 4. Redis Connection (Singleton)
$redis = null;
try {
if (extension_loaded('redis')) {
$redis = new Redis();
$redisHost = getenv('REDIS_HOST') ?: '127.0.0.1';
$redisPort = (int)(getenv('REDIS_PORT') ?: 6379);
$redisPass = getenv('REDIS_PASSWORD');
if ($redis->connect($redisHost, $redisPort, 1.5)) {
if ($redisPass) $redis->auth($redisPass);
$redis->setOption(Redis::OPT_PREFIX, 'intaleq:');
} else {
$redis = null;
}
}
} catch (Exception $e) {
error_log("[REDIS] Connection failed: " . $e->getMessage());
$redis = null;
}
// 5. تحميل الـ Services الأساسية
require_once __DIR__ . '/Security/EncryptionHelper.php';
require_once __DIR__ . '/Database/Database.php';
require_once __DIR__ . '/Auth/RateLimiter.php';
require_once __DIR__ . '/Auth/JwtService.php';
// لا نحمّل OtpService و FcmService إلا عند الحاجة (Lazy)
// 6. تهيئة Encryption Helper العام (للتوافقية)
// يتم استخدام .enckey (32 بايت) لتشفير البيانات
$encKey = trim(@file_get_contents('/home/intaleq-api/.enckey') ?: '');
if (!$encKey) {
$encKey = getenv('ENC_KEY') ?: '';
}
if (!$encKey || strlen($encKey) !== 32) {
error_log("[FATAL] Encryption key (.enckey) is missing or invalid length (must be 32 bytes).");
http_response_code(500);
exit(json_encode(['error' => 'Server configuration error: Encryption key issue']));
}
$encryptionHelper = new EncryptionHelper($encKey);

178
core/helpers.php Normal file
View File

@@ -0,0 +1,178 @@
<?php
// ============================================================
// core/helpers.php — دوال مساعدة موحدة
// ============================================================
// ── فلترة المدخلات (محسّنة) ─────────────────────────────────
function filterRequest(string $name, string $type = 'string'): mixed
{
// قراءة من POST أو JSON body
$value = null;
if (isset($_POST[$name]) && $_POST[$name] !== '') {
$value = $_POST[$name];
} else {
// محاولة قراءة من JSON body
static $jsonBody = null;
if ($jsonBody === null) {
$raw = file_get_contents('php://input');
$jsonBody = json_decode($raw, true) ?? [];
}
$value = $jsonBody[$name] ?? null;
}
if ($value === null || $value === '') return null;
$value = trim((string)$value);
// إزالة control characters
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value);
return match ($type) {
'int' => filter_var($value, FILTER_VALIDATE_INT) !== false ? (int)$value : null,
'float' => filter_var($value, FILTER_VALIDATE_FLOAT) !== false ? (float)$value : null,
'email' => filter_var($value, FILTER_VALIDATE_EMAIL) ?: null,
'url' => filter_var($value, FILTER_VALIDATE_URL) ?: null,
'bool' => filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE),
default => $value, // string — بدون htmlspecialchars (نتركه لـ PDO)
};
}
// ── ردود JSON موحدة ─────────────────────────────────────────
function jsonSuccess(mixed $data = null, string $message = 'success', int $code = 200): never
{
http_response_code($code);
// توحيد الأسلوب ليكون متوافقاً مع الكود القديم (وضع البيانات في message)
$payload = ($data !== null && (!empty($data) || is_array($data))) ? $data : $message;
echo json_encode(['status' => 'success', 'message' => $payload], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function jsonError(string $message, int $code = 400, mixed $extra = null): never
{
http_response_code($code);
$response = ['status' => 'failure', 'message' => $message];
if ($extra !== null) $response['details'] = $extra;
echo json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
// (للتوافق مع الكود القديم)
function printSuccess(mixed $message = 'success'): void
{
echo json_encode(['status' => 'success', 'message' => $message], JSON_UNESCAPED_UNICODE);
}
function printFailure(mixed $message = 'failure'): void
{
echo json_encode(['status' => 'failure', 'message' => $message], JSON_UNESCAPED_UNICODE);
}
function result(int $count): void
{
if ($count > 0) {
printSuccess();
} else {
printFailure();
}
}
function sendEmail(string $from, string $to, string $title, string $body): void
{
$header = "From: $from\nCC: $from";
mail($to, $title, $body, $header);
}
// ── رفع صورة آمن ──────────────────────────────────────────────
function uploadImageSecure(
string $fileKey,
string $targetDir,
string $prefix = '',
array $allowedMimes = ['image/jpeg', 'image/png', 'image/webp']
): array {
if (!isset($_FILES[$fileKey]) || $_FILES[$fileKey]['error'] !== UPLOAD_ERR_OK) {
return ['success' => false, 'error' => 'File upload error'];
}
$file = $_FILES[$fileKey];
$maxSize = 5 * 1024 * 1024; // 5MB
// حجم الملف
if ($file['size'] > $maxSize) {
return ['success' => false, 'error' => 'File too large (max 5MB)'];
}
// MIME validation حقيقي (ليس extension فقط)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
if (!in_array($mimeType, $allowedMimes, true)) {
return ['success' => false, 'error' => "Invalid file type: $mimeType"];
}
// اسم ملف آمن وعشوائي
$ext = match ($mimeType) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
default => 'bin',
};
$filename = ($prefix ? "{$prefix}_" : '') . bin2hex(random_bytes(8)) . ".$ext";
if (!is_dir($targetDir)) {
mkdir($targetDir, 0750, true);
}
$targetPath = rtrim($targetDir, '/') . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
return ['success' => false, 'error' => 'Failed to move uploaded file'];
}
return ['success' => true, 'filename' => $filename, 'path' => $targetPath];
}
// ── تحميل ملف .env ───────────────────────────────────────────
function loadEnvironment(string $path): void
{
if (!file_exists($path)) {
error_log("[ENV] File not found: $path");
return;
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (str_starts_with(trim($line), '#')) continue;
if (!str_contains($line, '=')) continue;
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value, " \t\n\r\0\x0B\"'");
if ($key && !getenv($key)) {
putenv("$key=$value");
$_ENV[$key] = $value;
}
}
}
// ── Logging منظم ──────────────────────────────────────────────
function securityLog(string $message, array $context = []): void
{
$logDir = __DIR__ . '/../logs';
if (!is_dir($logDir)) {
@mkdir($logDir, 0777, true);
}
$entry = date('Y-m-d H:i:s') . ' [SECURITY] ' . $message;
if ($context) $entry .= ' | ' . json_encode($context, JSON_UNESCAPED_UNICODE);
@error_log($entry . PHP_EOL, 3, $logDir . '/security.log');
}
function appLog(string $message, string $level = 'INFO'): void
{
$logDir = __DIR__ . '/../logs';
if (!is_dir($logDir)) {
@mkdir($logDir, 0777, true);
}
$entry = date('Y-m-d H:i:s') . " [$level] " . $message;
@error_log($entry . PHP_EOL, 3, $logDir . '/app.log');
}
function debugLog(string $message): void
{
appLog($message, 'DEBUG');
}