diff --git a/backend/.env.example b/backend/.env.example index cad4f15..3fcc81c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -102,6 +102,14 @@ MAIL_PASS= APP_ENV=production APP_DEBUG=false APP_NAME=Siro +APP_DOMAIN=api-syria.siromove.com + +# ============================================================================= +# Nabeh Integration (server-to-server API key) +# Must match NABEH_API_KEY in Nabeh's .env +# ============================================================================= +NABEH_API_KEY= +SECRET_KEY_HMAC= # ============================================================================= # Security Configuration - Fingerprint diff --git a/backend/nabeh/driver_status.php b/backend/nabeh/driver_status.php new file mode 100644 index 0000000..d5a7729 --- /dev/null +++ b/backend/nabeh/driver_status.php @@ -0,0 +1,91 @@ + 'failure', 'message' => 'Unauthorized']); + exit; +} + +$phone = $_GET['phone'] ?? ''; + +if (empty($phone)) { + http_response_code(400); + echo json_encode(['status' => 'failure', 'message' => 'Phone parameter required']); + exit; +} + +try { + $db = Database::get('main'); + global $encryptionHelper; + + $encryptedPhone = $encryptionHelper->encryptData($phone); + + $stmt = $db->prepare(" + SELECT d.id, d.phone, d.first_name, d.last_name, d.status, d.created_at, + cr.id as car_id, cr.make, cr.model, cr.year, cr.car_plate, cr.status as car_status + FROM driver d + LEFT JOIN CarRegistration cr ON cr.driverID = d.id + WHERE d.phone = :phone + LIMIT 1 + "); + $stmt->execute([ + ':phone' => $encryptedPhone, + ]); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$result) { + echo json_encode([ + 'status' => 'success', + 'data' => null, + 'message' => 'Driver not found' + ]); + exit; + } + + $decryptedPhone = $encryptionHelper->decryptData($result['phone']); + $decryptedFirstName = $encryptionHelper->decryptData($result['first_name']); + $decryptedLastName = $encryptionHelper->decryptData($result['last_name']); + + $docStmt = $db->prepare("SELECT doc_type, link FROM driver_documents WHERE driverID = :driverID"); + $docStmt->execute([':driverID' => $result['id']]); + $documents = $docStmt->fetchAll(PDO::FETCH_ASSOC); + + echo json_encode([ + 'status' => 'success', + 'data' => [ + 'driver_id' => $result['id'], + 'phone' => $decryptedPhone, + 'name' => trim($decryptedFirstName . ' ' . $decryptedLastName), + 'status' => $result['status'], + 'registered_at' => $result['created_at'], + 'car' => [ + 'id' => $result['car_id'], + 'make' => $result['make'], + 'model' => $result['model'], + 'year' => $result['year'], + 'plate' => $result['car_plate'], + 'status' => $result['car_status'], + ], + 'documents' => $documents, + ] + ], JSON_UNESCAPED_UNICODE); + +} catch (\Exception $e) { + error_log("[Nabeh Status Error] " . $e->getMessage()); + http_response_code(500); + echo json_encode(['status' => 'failure', 'message' => 'Internal server error']); +} diff --git a/backend/nabeh/query.php b/backend/nabeh/query.php new file mode 100644 index 0000000..07db703 --- /dev/null +++ b/backend/nabeh/query.php @@ -0,0 +1,252 @@ + 'failure', 'message' => 'Unauthorized: invalid API key']); + exit; +} + +$input = []; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $raw = file_get_contents('php://input'); + $input = json_decode($raw, true) ?: []; +} else { + $input = $_GET; +} + +$queryType = $input['query_type'] ?? ''; +$phone = preg_replace('/[^0-9]/', '', $input['phone'] ?? ''); +$driverId = $input['driver_id'] ?? ''; +$tripId = $input['trip_id'] ?? ''; +$limit = min((int)($input['limit'] ?? 10), 50); + +if (empty($queryType)) { + http_response_code(400); + echo json_encode(['status' => 'failure', 'message' => 'query_type is required. Options: driver_info, driver_trips, driver_stats, trip_detail']); + exit; +} + +$validTypes = ['driver_info', 'driver_trips', 'driver_stats', 'trip_detail']; +if (!in_array($queryType, $validTypes, true)) { + http_response_code(400); + echo json_encode(['status' => 'failure', 'message' => 'Invalid query_type. Options: ' . implode(', ', $validTypes)]); + exit; +} + +try { + global $encryptionHelper; + $mainDb = Database::get('main'); + $rideDb = Database::get('ride'); + + // ======================================================================== + if ($queryType === 'driver_info') { + if (empty($phone)) { + jsonError('phone parameter is required'); + } + + $encryptedPhone = $encryptionHelper->encryptData($phone); + + $stmt = $mainDb->prepare(" + SELECT d.id, d.phone, d.first_name, d.last_name, d.name_arabic, + d.status, d.created_at, d.birthdate, d.gender, d.site, + cr.id as car_id, cr.make, cr.model, cr.year, cr.car_plate, + cr.color, cr.color_hex, cr.fuel, cr.vin, + cr.status as car_status, cr.expiration_date + FROM driver d + LEFT JOIN CarRegistration cr ON cr.driverID = d.id + WHERE d.phone = :phone OR d.email LIKE :phoneLike + LIMIT 1 + "); + $stmt->execute([ + ':phone' => $encryptedPhone, + ':phoneLike' => $phone . '%', + ]); + $driver = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$driver) { + echo json_encode(['status' => 'success', 'data' => null, 'message' => 'Driver not found']); + exit; + } + + $decrypt = function($val) use ($encryptionHelper) { + return $val ? $encryptionHelper->decryptData($val) : $val; + }; + + echo json_encode([ + 'status' => 'success', + 'data' => [ + 'driver_id' => $driver['id'], + 'phone' => $decrypt($driver['phone']), + 'first_name' => $decrypt($driver['first_name']), + 'last_name' => $decrypt($driver['last_name']), + 'name_arabic' => $decrypt($driver['name_arabic']), + 'gender' => $decrypt($driver['gender']), + 'birthdate' => $driver['birthdate'], + 'status' => $driver['status'], + 'site' => $decrypt($driver['site']), + 'registered_at' => $driver['created_at'], + 'car' => $driver['car_id'] ? [ + 'id' => $driver['car_id'], + 'make' => $driver['make'], + 'model' => $driver['model'], + 'year' => $driver['year'], + 'plate' => $driver['car_plate'], + 'color' => $driver['color'], + 'color_hex' => $driver['color_hex'], + 'fuel' => $driver['fuel'], + 'vin' => $decrypt($driver['vin']), + 'status' => $driver['car_status'], + 'expiration_date' => $driver['expiration_date'], + ] : null, + ], + ], JSON_UNESCAPED_UNICODE); + exit; + } + + // ======================================================================== + if ($queryType === 'driver_trips') { + if (empty($driverId) && empty($phone)) { + jsonError('driver_id or phone is required'); + } + + if (empty($driverId) && !empty($phone)) { + $encryptedPhone = $encryptionHelper->encryptData($phone); + $stmt = $mainDb->prepare("SELECT id FROM driver WHERE phone = :phone LIMIT 1"); + $stmt->execute([':phone' => $encryptedPhone]); + $driverRow = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$driverRow) { + echo json_encode(['status' => 'success', 'data' => [], 'message' => 'Driver not found']); + exit; + } + $driverId = $driverRow['id']; + } + + $stmt = $rideDb->prepare(" + SELECT id, start_location, end_location, date, time, endtime, + price, price_for_driver, price_for_passenger, + status, paymentMethod, carType, distance, created_at + FROM ride + WHERE driver_id = :driver_id + ORDER BY created_at DESC + LIMIT :lim + "); + $stmt->bindValue(':driver_id', $driverId, PDO::PARAM_STR); + $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); + $stmt->execute(); + $trips = $stmt->fetchAll(PDO::FETCH_ASSOC); + + echo json_encode([ + 'status' => 'success', + 'data' => $trips, + 'count' => count($trips), + ], JSON_UNESCAPED_UNICODE); + exit; + } + + // ======================================================================== + if ($queryType === 'driver_stats') { + if (empty($driverId) && empty($phone)) { + jsonError('driver_id or phone is required'); + } + + if (empty($driverId) && !empty($phone)) { + $encryptedPhone = $encryptionHelper->encryptData($phone); + $stmt = $mainDb->prepare("SELECT id FROM driver WHERE phone = :phone LIMIT 1"); + $stmt->execute([':phone' => $encryptedPhone]); + $driverRow = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$driverRow) { + echo json_encode(['status' => 'success', 'data' => null, 'message' => 'Driver not found']); + exit; + } + $driverId = $driverRow['id']; + } + + $stmt = $rideDb->prepare(" + SELECT + COUNT(*) as total_trips, + COALESCE(SUM(price_for_driver), 0) as total_earnings, + COALESCE(SUM(price_for_passenger), 0) as total_collected, + COALESCE(AVG(price_for_driver), 0) as avg_earning_per_trip, + COALESCE(SUM(distance), 0) as total_distance, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_trips, + COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_trips + FROM ride + WHERE driver_id = :driver_id + "); + $stmt->execute([':driver_id' => $driverId]); + $stats = $stmt->fetch(PDO::FETCH_ASSOC); + + $driverStmt = $mainDb->prepare("SELECT status, created_at FROM driver WHERE id = :id LIMIT 1"); + $driverStmt->execute([':id' => $driverId]); + $driverStatus = $driverStmt->fetch(PDO::FETCH_ASSOC); + + echo json_encode([ + 'status' => 'success', + 'data' => [ + 'driver_id' => $driverId, + 'status' => $driverStatus['status'] ?? 'unknown', + 'registered_at' => $driverStatus['created_at'] ?? null, + 'stats' => [ + 'total_trips' => (int)$stats['total_trips'], + 'completed_trips' => (int)$stats['completed_trips'], + 'cancelled_trips' => (int)$stats['cancelled_trips'], + 'total_earnings' => (float)$stats['total_earnings'], + 'total_collected' => (float)$stats['total_collected'], + 'avg_earning_per_trip' => (float)$stats['avg_earning_per_trip'], + 'total_distance_km' => (float)$stats['total_distance'], + ], + ], + ], JSON_UNESCAPED_UNICODE); + exit; + } + + // ======================================================================== + if ($queryType === 'trip_detail') { + if (empty($tripId)) { + jsonError('trip_id is required'); + } + + $stmt = $rideDb->prepare(" + SELECT r.*, + p.first_name as passenger_first_name, + p.last_name as passenger_last_name, + p.phone as passenger_phone + FROM ride r + LEFT JOIN driver p ON p.id = r.passenger_id + WHERE r.id = :id + LIMIT 1 + "); + $stmt->execute([':id' => $tripId]); + $trip = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$trip) { + echo json_encode(['status' => 'success', 'data' => null, 'message' => 'Trip not found']); + exit; + } + + echo json_encode([ + 'status' => 'success', + 'data' => $trip, + ], JSON_UNESCAPED_UNICODE); + exit; + } + +} catch (\Exception $e) { + error_log("[Nabeh Query Error] " . $e->getMessage()); + http_response_code(500); + echo json_encode(['status' => 'failure', 'message' => 'Internal server error']); +} diff --git a/backend/nabeh/register.php b/backend/nabeh/register.php new file mode 100644 index 0000000..e280da0 --- /dev/null +++ b/backend/nabeh/register.php @@ -0,0 +1,218 @@ + 'failure', 'message' => 'Method not allowed']); + exit; +} + +$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? ''; +$expectedKey = getenv('NABEH_API_KEY') ?: ''; + +if (empty($apiKey) || $apiKey !== $expectedKey) { + http_response_code(401); + echo json_encode(['status' => 'failure', 'message' => 'Unauthorized: invalid API key']); + exit; +} + +// Rate limiting +$rateLimitFile = __DIR__ . '/../logs/nabeh_rate_' . md5($_SERVER['REMOTE_ADDR'] ?? 'unknown') . '.lock'; +$rateLimitWindow = 60; +$rateLimitMax = 5; + +$now = time(); +$attempts = []; +if (file_exists($rateLimitFile)) { + $attempts = json_decode(file_get_contents($rateLimitFile), true) ?: []; + $attempts = array_filter($attempts, fn($t) => $t > ($now - $rateLimitWindow)); +} +if (count($attempts) >= $rateLimitMax) { + http_response_code(429); + echo json_encode(['status' => 'failure', 'message' => 'Too many requests. Try again later.']); + exit; +} +$attempts[] = $now; +file_put_contents($rateLimitFile, json_encode($attempts), LOCK_EX); + +$input = json_decode(file_get_contents('php://input'), true); +if (!$input) { + http_response_code(400); + echo json_encode(['status' => 'failure', 'message' => 'Invalid JSON body']); + exit; +} + +$phone = preg_replace('/[^0-9]/', '', $input['phone'] ?? ''); +$password = $input['password'] ?? substr(md5($phone . time()), 0, 12); +$firstName = $input['first_name'] ?? $input['name_arabic'] ?? ''; +$lastName = $input['last_name'] ?? '-'; +$nameArabic = $input['name_arabic'] ?? $firstName; +$nationalNumber = $input['national_number'] ?? ''; +$birthdate = $input['birthdate'] ?? ''; +$address = $input['address'] ?? ''; +$gender = $input['gender'] ?? 'Male'; +$site = $input['site'] ?? ''; + +$vin = $input['vin'] ?? ''; +$carPlate = $input['car_plate'] ?? ''; +$make = $input['make'] ?? ''; +$model = $input['model'] ?? ''; +$year = $input['year'] ?? ''; +$color = $input['color'] ?? ''; +$colorHex = $input['color_hex'] ?? '#000000'; +$owner = $input['owner'] ?? ''; +$fuel = $input['fuel'] ?? 'Petrol'; +$expirationDate = $input['expiration_date'] ?? ''; + +$idFront = $input['id_front'] ?? $input['id_front_url'] ?? ''; +$idBack = $input['id_back'] ?? $input['id_back_url'] ?? ''; +$driverLicense = $input['driver_license'] ?? $input['driver_license_front_url'] ?? ''; +$driverLicenseBack = $input['driver_license_back'] ?? $input['driver_license_back_url'] ?? ''; +$carLicenseFront = $input['car_license_front'] ?? $input['vehicle_license_front_url'] ?? ''; +$carLicenseBack = $input['car_license_back'] ?? $input['vehicle_license_back_url'] ?? ''; +$criminalRecord = $input['criminal_record'] ?? $input['criminal_record_url'] ?? ''; +$profilePicture = $input['profile_picture'] ?? ''; + +if (empty($phone)) { + jsonError('Phone number is required'); +} +if (empty($firstName)) { + jsonError('First name is required'); +} +if (empty($vin) || empty($carPlate) || empty($make) || empty($model) || empty($year)) { + jsonError('Car details (vin, plate, make, model, year) are required'); +} + +try { + $db = Database::get('main'); + $driverId = 'DRV' . date('YmdHis') . rand(100, 999); + $hashedPassword = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]); + + global $encryptionHelper; + $encryptedPhone = $encryptionHelper->encryptData($phone); + $encryptedFirstName = $encryptionHelper->encryptData($firstName); + $encryptedLastName = $encryptionHelper->encryptData($lastName); + $encryptedNameArabic = $encryptionHelper->encryptData($nameArabic); + $encryptedNationalNumber = !empty($nationalNumber) ? $encryptionHelper->encryptData($nationalNumber) : ''; + $encryptedAddress = !empty($address) ? $encryptionHelper->encryptData($address) : ''; + $encryptedGender = $encryptionHelper->encryptData($gender); + $encryptedVin = $encryptionHelper->encryptData($vin); + $encryptedCarPlate = $encryptionHelper->encryptData($carPlate); + $encryptedOwner = $encryptionHelper->encryptData($owner); + $encryptedSite = !empty($site) ? $encryptionHelper->encryptData($site) : $encryptedAddress; + + $nowDate = date('Y-m-d H:i:s'); + + $driverStmt = $db->prepare(" + INSERT INTO driver ( + id, phone, email, password, gender, first_name, last_name, + name_arabic, national_number, address, site, birthdate, + status, created_at, updated_at + ) VALUES ( + :id, :phone, :email, :password, :gender, :first_name, :last_name, + :name_arabic, :national_number, :address, :site, :birthdate, + 'yet', :created_at, :updated_at + ) + "); + $driverStmt->execute([ + ':id' => $driverId, + ':phone' => $encryptedPhone, + ':email' => $phone . '@intaleqapp.com', + ':password' => $hashedPassword, + ':gender' => $encryptedGender, + ':first_name' => $encryptedFirstName, + ':last_name' => $encryptedLastName, + ':name_arabic' => $encryptedNameArabic, + ':national_number' => $encryptedNationalNumber, + ':address' => $encryptedAddress, + ':site' => $encryptedSite, + ':birthdate' => $birthdate, + ':created_at' => $nowDate, + ':updated_at' => $nowDate, + ]); + + $carStmt = $db->prepare(" + INSERT INTO CarRegistration ( + driverID, vin, car_plate, make, model, year, + expiration_date, color, owner, color_hex, fuel, + isDefault, status, created_at, vehicle_category_id + ) VALUES ( + :driverID, :vin, :car_plate, :make, :model, :year, + :expiration_date, :color, :owner, :color_hex, :fuel, + 1, 'yet', :created_at, 1 + ) + "); + $carStmt->execute([ + ':driverID' => $driverId, + ':vin' => $encryptedVin, + ':car_plate' => $encryptedCarPlate, + ':make' => $make, + ':model' => $model, + ':year' => $year, + ':expiration_date' => $expirationDate ?: $nowDate, + ':color' => $color, + ':owner' => $encryptedOwner, + ':color_hex' => $colorHex, + ':fuel' => $fuel, + ':created_at' => $nowDate, + ]); + $carRegId = $db->lastInsertId(); + + $docTypes = [ + 'id_front' => $idFront, + 'id_back' => $idBack, + 'driver_license' => $driverLicense, + 'driver_license_back' => $driverLicenseBack, + 'car_license_front' => $carLicenseFront, + 'car_license_back' => $carLicenseBack, + 'criminal_record' => $criminalRecord, + 'profile_picture' => $profilePicture, + ]; + + $docStmt = $db->prepare(" + INSERT INTO driver_documents (driverID, doc_type, image_name, link, upload_date) + VALUES (:driverID, :doc_type, :image_name, :link, :upload_date) + "); + foreach ($docTypes as $docType => $link) { + if (!empty($link)) { + $docStmt->execute([ + ':driverID' => $driverId, + ':doc_type' => $docType, + ':image_name' => $driverId . '_' . $docType, + ':link' => $link, + ':upload_date' => $nowDate, + ]); + } + } + + error_log("[Nabeh Registration] New driver registered: {$driverId}, Phone: {$phone}"); + + echo json_encode([ + 'status' => 'success', + 'message' => [ + 'status' => 'success', + 'driverID' => $driverId, + 'carRegID' => $carRegId, + ] + ], JSON_UNESCAPED_UNICODE); + +} catch (\Exception $e) { + error_log("[Nabeh Registration Error] " . $e->getMessage()); + http_response_code(500); + echo json_encode([ + 'status' => 'failure', + 'message' => 'Internal server error: ' . $e->getMessage() + ]); +} diff --git a/backend/nabeh/upload_document.php b/backend/nabeh/upload_document.php new file mode 100644 index 0000000..18c7879 --- /dev/null +++ b/backend/nabeh/upload_document.php @@ -0,0 +1,164 @@ + 'failure', 'message' => 'Method not allowed']); + exit; +} + +$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? ''; +$expectedKey = getenv('NABEH_API_KEY') ?: ''; + +if (empty($apiKey) || $apiKey !== $expectedKey) { + http_response_code(401); + echo json_encode(['status' => 'failure', 'message' => 'Unauthorized: invalid API key']); + exit; +} + +// Rate limiting +$rateLimitFile = __DIR__ . '/../logs/nabeh_upload_rate_' . md5($_SERVER['REMOTE_ADDR'] ?? 'unknown') . '.lock'; +$rateLimitWindow = 60; +$rateLimitMax = 10; + +$nowTime = time(); +$attempts = []; +if (file_exists($rateLimitFile)) { + $attempts = json_decode(file_get_contents($rateLimitFile), true) ?: []; + $attempts = array_filter($attempts, fn($t) => $t > ($nowTime - $rateLimitWindow)); +} +if (count($attempts) >= $rateLimitMax) { + http_response_code(429); + echo json_encode(['status' => 'failure', 'message' => 'Too many requests. Try again later.']); + exit; +} +$attempts[] = $nowTime; +file_put_contents($rateLimitFile, json_encode($attempts), LOCK_EX); + +const MAX_FILE_MB = 5; +const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/webp']; +const UPLOAD_ROOT = __DIR__ . '/../private_uploads'; +const SIGNED_TTL_SEC = 172800; + +$signSecret = getenv('SECRET_KEY_HMAC') ?: ''; +if (empty($signSecret)) { + uploadLog('[Nabeh Upload] SECRET_KEY_HMAC not configured', 'ERROR'); + http_response_code(500); + echo json_encode(['status' => 'failure', 'message' => 'Server configuration error']); + exit; +} + +$host = getenv('APP_DOMAIN') ?: 'api-syria.siromove.com'; +$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; +define('PUBLIC_BASE', "$protocol://$host/siro"); + +if (!is_dir(UPLOAD_ROOT)) { + @mkdir(UPLOAD_ROOT, 0700, true); +} + +uploadLog("[Nabeh Upload] Document upload started"); + +$allowedDocTypes = [ + 'id_front', 'id_back', + 'driver_license_front', 'driver_license_back', + 'car_license_front', 'car_license_back', + 'criminal_record', 'profile_picture', +]; + +$driverId = $_POST['driver_id'] ?? ''; +$docType = $_POST['doc_type'] ?? ''; + +if (empty($driverId) || empty($docType)) { + jsonError('driver_id and doc_type are required.'); +} + +$driverIdSafe = preg_replace('/[^A-Za-z0-9_\-]/', '_', $driverId); + +if (!in_array($docType, $allowedDocTypes, true)) { + jsonError("Invalid doc_type. Allowed: " . implode(', ', $allowedDocTypes)); +} + +if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { + $errCode = $_FILES['file']['error'] ?? 'missing_file'; + uploadLog("[Nabeh Upload] File upload error. Code: $errCode", 'ERROR'); + jsonError('No file uploaded or upload error.'); +} + +$tmpPath = $_FILES['file']['tmp_name']; +$size = filesize($tmpPath); +if ($size === false || $size <= 0) { + jsonError('Invalid file size.'); +} +if ($size > MAX_FILE_MB * 1024 * 1024) { + jsonError('File too large. Max ' . MAX_FILE_MB . ' MB.'); +} + +$finfo = new finfo(FILEINFO_MIME_TYPE); +$mime = $finfo->file($tmpPath) ?: 'application/octet-stream'; +if (!in_array($mime, ALLOWED_MIMES, true)) { + jsonError("Unsupported file type: $mime"); +} + +$extMap = [ + 'image/jpeg' => '.jpg', + 'image/png' => '.png', + 'image/webp' => '.webp', +]; +$ext = $extMap[$mime]; + +$h = hash('sha1', $driverIdSafe); +$subdir = substr($h, 0, 2) . '/' . substr($h, 2, 2); +$destDir = UPLOAD_ROOT . '/' . $subdir; +if (!is_dir($destDir)) { @mkdir($destDir, 0700, true); } + +$serverName = "{$driverIdSafe}__{$docType}{$ext}"; +$destPath = $destDir . '/' . $serverName; + +$resolvedDest = realpath($destPath) ?: $destPath; +$resolvedRoot = realpath(UPLOAD_ROOT) ?: UPLOAD_ROOT; + +if (is_file($destPath) && str_starts_with($resolvedDest, $resolvedRoot)) { + @unlink($destPath); +} + +if (!move_uploaded_file($tmpPath, $destPath)) { + jsonError('Failed to save the uploaded file.'); +} +@chmod($destPath, 0600); + +$extShort = ltrim($ext, '.'); +$expires = time() + SIGNED_TTL_SEC; +$message = $driverIdSafe . ':' . $docType . ':' . $extShort . ':' . $expires; +$signature = hash_hmac('sha256', $message, $signSecret); + +$fileUrl = PUBLIC_BASE . '/secure_image.php' + . '?driver_id=' . urlencode($driverIdSafe) + . '&doc_type=' . urlencode($docType) + . '&ext=' . urlencode($extShort) + . '&expires=' . $expires + . '&signature=' . urlencode($signature); + +uploadLog("[Nabeh Upload] Document uploaded successfully. Type: $docType, Size: $size"); + +printSuccess([ + 'status' => 'success', + 'success_file' => true, + 'file_url' => $fileUrl, + 'file_name' => $serverName, + 'driver_id' => $driverIdSafe, + 'doc_type' => $docType, + 'mime_type' => $mime, + 'size_bytes' => $size, + 'expires_at' => date('c', $expires), +]);