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) }; } /** * مفتاح بحث ثابت لجداول التحقق (token_verification*, phone_verification*). * * هذه الجداول تستخدم رقم الهاتف كمفتاح بحث لا كبيان يُعرض: يُكتب عند الإرسال * ويُقرأ عند التحقق. تخزينه مشفّراً كان يعمل فقط لأن التشفير حتمي — ومع * AES-GCM العشوائي يُنتج الإرسال والتحقق قيمتين مختلفتين فلا ينجح أي رمز. * * البديل: بصمة HMAC حتمية للرقم بعد تطبيعه. لا تحتاج تعديل المخطط (العمود * نصي أصلاً)، وتوحّد صيغ الرقم المحلية والدولية، ولا يمكن عكسها بلا المفتاح. */ function otpPhoneKey(?string $phone): string { if ($phone === null || trim($phone) === '') return ''; global $blindIndex, $encryptionHelper; if ($blindIndex) { return 'K:' . $blindIndex->index('otp.phone', $phone); } // بلا BLIND_INDEX_PEPPER نعود للسلوك القديم حتى لا يتعطل التحقق return $encryptionHelper ? $encryptionHelper->encryptData($phone) : $phone; } // ── ردود 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 { $from = str_replace(["\r", "\n", "\r\n"], '', $from); $to = str_replace(["\r", "\n", "\r\n"], '', $to); $title = str_replace(["\r", "\n", "\r\n"], '', $title); $header = "From: $from\r\n"; $header .= "Reply-To: $from\r\n"; $header .= "MIME-Version: 1.0\r\n"; $header .= "Content-Type: text/html; charset=UTF-8\r\n"; 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, 0750, 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, 0750, true); } $entry = date('Y-m-d H:i:s') . " [$level] " . $message; @error_log($entry . PHP_EOL, 3, $logDir . '/app.log'); } function uploadLog(string $message, string $level = 'INFO', array $context = []): void { $logDir = __DIR__ . '/../logs'; if (!is_dir($logDir)) { @mkdir($logDir, 0750, true); } if (!isset($context['ip'])) { $context['ip'] = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; } if (!isset($context['user_agent'])) { $context['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'; } if (isset($context['upload_error_code'])) { $errCode = $context['upload_error_code']; $context['upload_error_desc'] = match ($errCode) { UPLOAD_ERR_OK => 'UPLOAD_ERR_OK (0): No error, file uploaded successfully.', UPLOAD_ERR_INI_SIZE => 'UPLOAD_ERR_INI_SIZE (1): The uploaded file exceeds the upload_max_filesize directive in php.ini.', UPLOAD_ERR_FORM_SIZE => 'UPLOAD_ERR_FORM_SIZE (2): The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.', UPLOAD_ERR_PARTIAL => 'UPLOAD_ERR_PARTIAL (3): The uploaded file was only partially uploaded (common on weak/3G networks).', UPLOAD_ERR_NO_FILE => 'UPLOAD_ERR_NO_FILE (4): No file was uploaded.', UPLOAD_ERR_NO_TMP_DIR => 'UPLOAD_ERR_NO_TMP_DIR (6): Missing a temporary folder.', UPLOAD_ERR_CANT_WRITE => 'UPLOAD_ERR_CANT_WRITE (7): Failed to write file to disk.', UPLOAD_ERR_EXTENSION => 'UPLOAD_ERR_EXTENSION (8): A PHP extension stopped the file upload.', default => "Unknown upload error code: $errCode", }; } $entry = date('Y-m-d H:i:s') . " [$level] " . $message; if ($context) { $entry .= ' | ' . json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } @error_log($entry . PHP_EOL, 3, $logDir . '/upload.log'); } function debugLog(string $message): void { appLog($message, 'DEBUG'); } function getInternalSocketKey(): string { $key = getenv('INTERNAL_SOCKET_KEY'); if ($key) { return trim($key); } $path = getenv('INTERNAL_SOCKET_KEY_PATH') ?: ''; if (file_exists($path)) { return trim((string)@file_get_contents($path)); } return ''; } /** * تطبيع رقم الهاتف إلى الصيغة الدولية بدون + (E.164 بدون +) * ناتج ثابت لأي مدخل من JO/SY/EG: * الأردن → 9627XXXXXXXX * سوريا → 9639XXXXXXX * مصر → 20XXXXXXXXXX * يُستخدم دائماً قبل التشفير وقبل الاستعلام. */ function normalizePhone(string $phone): string { $d = preg_replace('/\D+/', '', $phone); // سوريا: 09X → 963X | 9X (9 أرقام) → 963X | 963... مكتمل if (strlen($d) === 10 && str_starts_with($d, '09')) return '963' . substr($d, 1); if (strlen($d) === 9 && str_starts_with($d, '9')) return '963' . $d; if (strlen($d) === 12 && str_starts_with($d, '963')) return $d; // الأردن: 07X → 962X | 7X (9 أرقام) → 962X | 962... مكتمل if (strlen($d) === 10 && str_starts_with($d, '07')) return '962' . substr($d, 1); if (strlen($d) === 9 && str_starts_with($d, '7')) return '962' . $d; if (strlen($d) === 12 && str_starts_with($d, '962')) return $d; // مصر: 01X → 20X | 201... مكتمل if (strlen($d) === 11 && str_starts_with($d, '01')) return '20' . substr($d, 1); if (strlen($d) === 13 && str_starts_with($d, '20')) return $d; return $d; // رقم خارج النطاق — يُعاد كما هو } /** * إخفاء جزء من رقم الهاتف قبل عرضه لدور لا يملك صلاحية رؤيته كاملاً. * * نموذج الصلاحيات في اللوحة: `admin` يراقب بأرقام مُخفاة جزئياً، * و`super_admin` وحده يرى الرقم كاملاً. * * يُبقي مقدّمة الدولة وآخر رقمين حتى يبقى الرقم مميّزاً للمتابعة التشغيلية * دون كشف هوية صاحبه. */ function maskPhone(?string $phone): ?string { if ($phone === null || $phone === '') return $phone; $d = preg_replace('/\D+/', '', $phone); $len = strlen($d); if ($len < 5) return str_repeat('*', max(0, $len)); $head = substr($d, 0, 3); $tail = substr($d, -2); return $head . str_repeat('*', $len - 5) . $tail; } /** * عنوان العميل الحقيقي خلف الوسطاء. * * سلسلة الطلب: العميل ← nginx المضيف ← nginx الحاوية ← php-fpm، فيصل * REMOTE_ADDR دائماً كعنوان الوسيط. الاعتماد عليه وحده جعل RateLimiter يضع * كل المستخدمين في دلو واحد، فمحاولات دخول فاشلة من عميل واحد تحجب الجميع. * * X-Forwarded-For قابل للتزوير من العميل، لذا لا نثق به إلا إذا كان * REMOTE_ADDR نفسه وسيطاً موثوقاً (شبكات خاصة/لوكال). خلاف ذلك نعيد * REMOTE_ADDR كما هو. */ function clientIp(): string { $remote = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; $isTrustedProxy = $remote !== 'unknown' && ( filter_var( $remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false ); if (!$isTrustedProxy) { return $remote; } $forwarded = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''; if ($forwarded === '') { return $remote; } // أول عنوان عام في السلسلة هو العميل؛ نتخطّى العناوين الخاصة التي // أضافها الوسطاء. foreach (explode(',', $forwarded) as $candidate) { $candidate = trim($candidate); if ($candidate === '') continue; if (filter_var( $candidate, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) !== false) { return $candidate; } } return $remote; }