The verification tables (token_verification*, phone_verification*) use the phone number as a lookup key: written when the code is sent, read when it is checked. Storing it encrypted worked only because encryptData() is deterministic — under AES-GCM the two sides would produce different ciphertexts and no code would ever verify, locking every user out of registration and OTP sign-in. otpPhoneKey() stores a keyed HMAC of the normalised number instead. No schema change is needed since the column is textual, local and international formats now resolve to the same key, and the value cannot be reversed without the pepper. It falls back to the previous behaviour when no pepper is configured. Applied to both sides of every affected flow — request/verify, and the driver and passenger send/verify pairs — including the OTP value itself where it is compared by equality rather than decrypted. auth/otp/verify.php already decrypts the token before comparing, so it needed no change there. Also adds ENCRYPTION_MODE to EncryptionHelper: encryptData() writes GCM when set to 'gcm', CBC otherwise. Verified in both directions — rows written under CBC stay readable after switching, and rows written under GCM stay readable after rolling back — so the switch is reversible by an environment variable. The admin console's own OTP is unaffected: it keys the table by the stored ciphertext read from adminUser, identical on both sides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
291 lines
11 KiB
PHP
291 lines
11 KiB
PHP
<?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)
|
|
};
|
|
}
|
|
|
|
|
|
/**
|
|
* مفتاح بحث ثابت لجداول التحقق (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; // رقم خارج النطاق — يُعاد كما هو
|
|
}
|
|
|