diff --git a/.gitignore b/.gitignore index 4126c5c2d8..4b671b1b6d 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,7 @@ mcp.json /vendor-backup .env.testing -nul \ No newline at end of file +nul + +# Agent +/.qwen diff --git a/app/Http/Controllers/Setting/PengaturanDatabaseController.php b/app/Http/Controllers/Setting/PengaturanDatabaseController.php index 230d676978..4bcbb102e7 100644 --- a/app/Http/Controllers/Setting/PengaturanDatabaseController.php +++ b/app/Http/Controllers/Setting/PengaturanDatabaseController.php @@ -72,16 +72,28 @@ public function createBackup() { try { Log::info('Starting backup process.'); - - Artisan::call('backup:run'); - - Log::info('Backup command output: ' . Artisan::output()); + + $exitCode = Artisan::call('backup:run'); + $output = Artisan::output(); + + Log::info('Backup command output: ' . $output); + Log::info('Backup exit code: ' . $exitCode); + + if ($exitCode !== 0) { + Log::error('Backup process failed with exit code: ' . $exitCode); + + return response()->json([ + 'success' => false, + 'message' => 'Backup gagal. Periksa log aplikasi untuk detail.', + ], 500); + } + Log::info('Ending backup process.'); - + return response()->json(['success' => true, 'message' => 'Backup completed successfully']); } catch (\Exception $e) { Log::error('Backup process failed: ' . $e->getMessage(), ['exception' => $e]); - + return response()->json(['success' => false, 'message' => 'Backup process failed', 'error' => $e->getMessage()], 500); } } @@ -148,46 +160,51 @@ public function restoreBackup(Request $request) 'backupFile' => 'required|file', ]); - // Validasi tipe file - $allowedExtensions = ['sql']; + $allowedExtensions = ['sql', 'zip']; $file = $request->file('backupFile'); - - // Validate file extension first using the original method - $extension = $file->getClientOriginalExtension(); - if (!in_array($extension, $allowedExtensions)) { - return response()->json(['message' => 'File harus berupa .sql'], 422); - } - - // Use FileUploadService for secure file upload + + $extension = strtolower($file->getClientOriginalExtension()); + if (! in_array($extension, $allowedExtensions)) { + return response()->json(['message' => 'File harus berupa .sql atau .zip'], 422); + } + $fileUploadService = new \App\Services\FileUploadService(); - - // Define allowed MIME types for sql files - $allowedMimes = ['application/octet-stream', 'text/plain', 'application/sql']; - - // Upload file securely to temp directory - $path = $fileUploadService->uploadSecure($file, 'backup-temp', $allowedMimes, 102400); // 100MB max - + + if ($extension === 'zip') { + $allowedMimes = \App\Services\FileUploadService::getAllowedMimes('archive'); + $maxSize = 512000; // 500MB + } else { + $allowedMimes = ['application/octet-stream', 'text/plain', 'application/sql']; + $maxSize = 102400; // 100MB + } + + $path = $fileUploadService->uploadSecure($file, 'backup-temp', $allowedMimes, $maxSize); + $filename = basename($path); - + $allowedChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-'; - if (!preg_match("/^[" . $allowedChars . "]+$/", $filename)) { + if (! preg_match("/^[" . $allowedChars . "]+$/", $filename)) { return response()->json(['message' => 'Nama file tidak valid, Hanya boleh mengandung huruf (a-z/A-Z), angka (0-9), titik (.), dan garis bawah (_)'], 422); } - // Simpan file ke direktori sementara - $file = $request->file('backupFile'); $setDir = 'backup-temp'; - // The path is already handled by the FileUploadService above, so we don't need this line anymore - // The path variable is already set from the uploadSecure method - + try { Log::info('Starting restore process.'); - $finalPath = Storage::path($path); - Log::info("Normalized SQL file path from upload: $finalPath"); + $finalPath = Storage::disk('public')->path($path); + + if ($extension === 'zip') { + Log::info('Restore from ZIP: ' . $finalPath); + $result = $this->restoreFromZip($finalPath); + + return response()->json([ + 'message' => "Restore berhasil. Database dan {$result['files_restored']} file asset telah dipulihkan.", + ], 200); + } - // Jalankan proses restore + Log::info('Normalized SQL file path from upload: ' . $finalPath); $this->runRestoreDatabase($finalPath); return response()->json(['message' => 'Database berhasil direstore.'], 200); @@ -195,8 +212,7 @@ public function restoreBackup(Request $request) Log::error('Restore error: ' . $e->getMessage()); return response()->json(['message' => $e->getMessage()], 500); } finally { - // Hapus file sementara - $this->deleteTemporaryDirectory(Storage::path($setDir)); + $this->deleteTemporaryDirectory(Storage::disk('public')->path($setDir)); Log::info('Direktori sementara berhasil dihapus.'); } } @@ -210,19 +226,24 @@ private function runRestoreDatabase($sqlFilePath) $dbUser = config('database.connections.mysql.username'); $dbPass = config('database.connections.mysql.password'); + // Gunakan path binary dari konfigurasi (sama seperti spatie untuk mysqldump) + $binaryPath = config('database.connections.mysql.dump.dump_binary_path', ''); + $mysqlBinary = $binaryPath + ? rtrim($binaryPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'mysql' + : 'mysql'; + // Buat command untuk restore database $command = sprintf( - 'mysql -h%s -P%s -u%s %s %s < "%s"', + '%s -h%s -P%s -u%s %s %s < "%s" 2>&1', + escapeshellarg($mysqlBinary), escapeshellarg($dbHost), escapeshellarg($dbPort), escapeshellarg($dbUser), - $dbPass !== '' ? '-p' . escapeshellarg($dbPass) : '', + $dbPass !== '' ? '--password=' . escapeshellarg($dbPass) : '', escapeshellarg($dbName), $sqlFilePath ); - // $this->info("Executing command: $command"); - exec($command, $output, $returnVar); // cek hasil @@ -234,6 +255,138 @@ private function runRestoreDatabase($sqlFilePath) Log::info('Database restored successfully.'); } + /** + * Restore file asset dan database dari file ZIP backup (spatie/laravel-backup). + */ + private function restoreFromZip($zipFilePath) + { + $zip = new \ZipArchive(); + + if ($zip->open($zipFilePath) !== true) { + throw new \Exception('Gagal membuka file ZIP. Pastikan file tidak rusak.'); + } + + $sqlDumpPath = null; + $filesRestored = 0; + $storageBase = storage_path('app'); + $tempBase = storage_path('app/public/backup-temp'); + $tempSqlPath = $tempBase . '/restore-dump.sql'; + + try { + for ($i = 0; $i < $zip->numFiles; $i++) { + $entry = $zip->getNameIndex($i); + + // Skip directories (entries ending with /) + if (str_ends_with($entry, '/')) { + continue; + } + + // Normalize backslash to forward slash (cross-platform ZIP entries) + $normalized = str_replace('\\', '/', $entry); + + // Database dump — ekstrak ke file terpisah untuk restore via mysql + if (str_starts_with($normalized, 'db-dumps/')) { + $zip->extractTo($tempBase, $entry); + $extractedPath = $tempBase . '/' . $entry; + if (file_exists($extractedPath)) { + rename($extractedPath, $tempSqlPath); + $sqlDumpPath = $tempSqlPath; + } + $dbDumpsDir = $tempBase . '/db-dumps'; + if (is_dir($dbDumpsDir)) { + $this->deleteTemporaryDirectory($dbDumpsDir); + } + continue; + } + + // File storage — map ke path relatif di dalam storage/app/ + $relativePath = $this->mapZipEntryToStoragePath($normalized); + if ($relativePath === null) { + continue; + } + + // Build target path absolut + $targetPath = $storageBase . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath); + $targetDir = dirname($targetPath); + + // Validasi: target harus berada di dalam storage/app/ + $normalizedTargetDir = str_replace('\\', '/', $targetDir); + $normalizedStorageBase = str_replace('\\', '/', $storageBase); + if (! str_starts_with($normalizedTargetDir, $normalizedStorageBase)) { + Log::warning('Skipping entry outside storage: ' . $entry); + continue; + } + + // Buat direktori jika belum ada + if (! is_dir($targetDir)) { + mkdir($targetDir, 0755, true); + } + + // Ekstrak file individual + $content = $zip->getFromIndex($i); + if ($content !== false) { + file_put_contents($targetPath, $content); + $filesRestored++; + } else { + Log::warning('Failed to extract from ZIP: ' . $entry); + } + } + + // Restore database dari SQL dump yang ditemukan di ZIP + if ($sqlDumpPath && file_exists($sqlDumpPath)) { + Log::info('Restoring database from ZIP dump: ' . $sqlDumpPath); + $this->runRestoreDatabase($sqlDumpPath); + } else { + Log::warning('No database dump found in ZIP — only file assets restored'); + } + + Log::info('Restore from ZIP completed: ' . $filesRestored . ' files restored'); + + return ['files_restored' => $filesRestored]; + } finally { + $zip->close(); + if (file_exists($tempSqlPath)) { + unlink($tempSqlPath); + } + } + } + + /** + * Map ZIP entry ke path relatif di dalam storage/app/. + * Menangani baik path absolute (backup lama, relative_path=null) maupun + * path relative (backup baru, relative_path=base_path()). + */ + private function mapZipEntryToStoragePath($entry) + { + $skipDirs = ['backup-storage/', 'backup-temp/', 'framework/', 'logs/', 'debugbar/']; + + $marker = 'storage/app/'; + $pos = strpos($entry, $marker); + + if ($pos === false) { + return null; + } + + $relativePath = substr($entry, $pos + strlen($marker)); + + // Security: reject paths containing directory traversal + if (str_contains($relativePath, '..')) { + return null; + } + + // Skip direktori yang dikecualikan + foreach ($skipDirs as $dir) { + if (str_starts_with($relativePath, $dir)) { + return null; + } + } + + if (empty($relativePath)) { + return null; + } + + return $relativePath; + } private function deleteTemporaryDirectory($path) { diff --git a/config/backup.php b/config/backup.php index 2b54adbbe0..463de2fc97 100644 --- a/config/backup.php +++ b/config/backup.php @@ -26,6 +26,8 @@ base_path('storage/debugbar'), base_path('storage/framework'), base_path('storage/logs'), + base_path('storage/app/backup-storage'), + base_path('storage/app/backup-temp'), ], /* @@ -43,7 +45,7 @@ * Set to `null` to include complete absolute path * Example: base_path() */ - 'relative_path' => null, + 'relative_path' => base_path(), ], /* diff --git a/resources/views/setting/pengaturan_database/table-backup.blade.php b/resources/views/setting/pengaturan_database/table-backup.blade.php index 31e9231022..489a51cf73 100644 --- a/resources/views/setting/pengaturan_database/table-backup.blade.php +++ b/resources/views/setting/pengaturan_database/table-backup.blade.php @@ -86,30 +86,22 @@ class: 'text-center', "_token": "{{ csrf_token() }}", }, beforeSend: function() { - restoreMessage.html('
Processing, please wait...
'); + restoreMessage.html('Sedang di proses, mohon tunggu...
'); btnBackup.removeClass("btn-social"); btnBackup.attr("disabled", true); - btnBackup.html(' Loading...'); + btnBackup.html(' Sedang di proses...'); }, success: function(response) { $('#data-backup-database').DataTable().ajax.reload(); - btnBackup.addClass("btn-social"); - btnBackup.attr("disabled", false); - btnBackup.html('Buat Cadangan Baru Database'); + restoreMessage.html( + 'Berhasil membuat salinan database.
' + ); }, error: function(xhr, status, error) { - restoreMessage.html('Error: ' + xhr.responseJSON - .message + '
'); - - btnBackup.addClass("btn-social"); - btnBackup.attr("disabled", false); - btnBackup.html('Buat Cadangan Baru Database'); + restoreMessage.html('Error: ' + (xhr.responseJSON?.message || 'Gagal melakukan backup.') + '
'); }, complete: function() { - restoreMessage.html( - 'Berhasil membuat salinan database.
' - ); btnBackup.addClass("btn-social"); btnBackup.attr("disabled", false); btnBackup.html('Buat Cadangan Baru Database'); diff --git a/resources/views/setting/pengaturan_database/table-restore.blade.php b/resources/views/setting/pengaturan_database/table-restore.blade.php index 14938f5137..636222d85e 100644 --- a/resources/views/setting/pengaturan_database/table-restore.blade.php +++ b/resources/views/setting/pengaturan_database/table-restore.blade.php @@ -2,10 +2,18 @@ @section('content_pengaturan_database')