From 024de5e57dc80067488b5a5aae8fc3a948b0b5d4 Mon Sep 17 00:00:00 2001 From: habibie11 Date: Fri, 3 Jul 2026 16:34:12 +0700 Subject: [PATCH 1/4] Fitur backup & restore file asset storage dari menu Pengaturan Database --- .gitignore | 5 +- .../Setting/PengaturanDatabaseController.php | 231 +++++++++++++++--- config/backup.php | 4 +- .../table-backup.blade.php | 20 +- .../table-restore.blade.php | 18 +- .../Settings/PengaturanDatabaseBackupTest.php | 61 +++++ .../PengaturanDatabaseRestoreTest.php | 122 +++++++++ tests/Unit/PengaturanDatabaseTest.php | 96 ++++++++ 8 files changed, 497 insertions(+), 60 deletions(-) create mode 100644 tests/Feature/Settings/PengaturanDatabaseBackupTest.php create mode 100644 tests/Feature/Settings/PengaturanDatabaseRestoreTest.php create mode 100644 tests/Unit/PengaturanDatabaseTest.php 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')
- - -

Unggah file database (.sql)

-
@@ -34,7 +42,7 @@ processData: false, contentType: false, success: function(response) { - restoreMessage.html('

Database restored successfully!

'); + restoreMessage.html('

' + response.message + '

'); buttonSubmit.attr("disabled", false) $('#restoreDatabaseForm')[0].reset(); }, diff --git a/tests/Feature/Settings/PengaturanDatabaseBackupTest.php b/tests/Feature/Settings/PengaturanDatabaseBackupTest.php new file mode 100644 index 0000000000..a33a83c4af --- /dev/null +++ b/tests/Feature/Settings/PengaturanDatabaseBackupTest.php @@ -0,0 +1,61 @@ +withoutMiddleware([ + Authenticate::class, + RoleMiddleware::class, + PermissionMiddleware::class, + CompleteProfile::class, + GlobalShareMiddleware::class, + ]); +}); + +describe('createBackup - response handling', function () { + test('mengembalikan success=true ketika backup berhasil (exit code 0)', function () { + Artisan::shouldReceive('call') + ->with('backup:run') + ->andReturn(0); + + Artisan::shouldReceive('output') + ->andReturn('Backup completed!'); + + $response = $this->postJson( + route('setting.pengaturan-database.runbackup'), + [], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(200); + $response->assertJson(['success' => true]); + }); + + test('mengembalikan success=false ketika backup gagal (exit code non-zero)', function () { + Artisan::shouldReceive('call') + ->with('backup:run') + ->andReturn(1); + + Artisan::shouldReceive('output') + ->andReturn('mysqldump not found'); + + $response = $this->postJson( + route('setting.pengaturan-database.runbackup'), + [], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(500); + $response->assertJson(['success' => false]); + $response->assertJsonPath('message', 'Backup gagal. Periksa log aplikasi untuk detail.'); + }); +}); diff --git a/tests/Feature/Settings/PengaturanDatabaseRestoreTest.php b/tests/Feature/Settings/PengaturanDatabaseRestoreTest.php new file mode 100644 index 0000000000..2cc21632de --- /dev/null +++ b/tests/Feature/Settings/PengaturanDatabaseRestoreTest.php @@ -0,0 +1,122 @@ +withoutMiddleware([ + Authenticate::class, + RoleMiddleware::class, + PermissionMiddleware::class, + CompleteProfile::class, + GlobalShareMiddleware::class, + ]); + + Storage::fake('public'); + + // Nonaktifkan upload_limit agar tidak mengganggu test + SettingAplikasi::updateOrCreate( + ['key' => 'upload_limit'], + ['value' => '0', 'type' => 'boolean', 'kategori' => 'sistem', 'description' => 'test', 'option' => '{}'] + ); +}); + +describe('restoreBackup - validasi ekstensi', function () { + test('menolak file dengan ekstensi tidak diizinkan (.txt)', function () { + $file = UploadedFile::fake()->create('backup.txt', 100); + + $response = $this->postJson( + route('setting.pengaturan-database.runrestore'), + ['backupFile' => $file], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(422); + $response->assertJson(['message' => 'File harus berupa .sql atau .zip']); + }); + + test('menolak file dengan ekstensi tidak diizinkan (.exe)', function () { + $file = UploadedFile::fake()->create('malware.exe', 100); + + $response = $this->postJson( + route('setting.pengaturan-database.runrestore'), + ['backupFile' => $file], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(422); + }); + + test('menolak request tanpa file', function () { + $response = $this->postJson( + route('setting.pengaturan-database.runrestore'), + [], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['backupFile']); + }); +}); + +describe('restoreBackup - file ZIP corrupt', function () { + test('mengembalikan error 500 untuk ZIP yang rusak', function () { + // Buat file dengan ZIP magic bytes (PK) tapi content corrupt + // agar pass MIME validation tapi gagal di ZipArchive::open() + $tmpFile = tempnam(sys_get_temp_dir(), 'test_') . '.zip'; + file_put_contents($tmpFile, "PK\x03\x04" . str_repeat("\x00", 100)); + + $file = new UploadedFile($tmpFile, 'corrupt.zip', 'application/zip', null, true); + + $response = $this->postJson( + route('setting.pengaturan-database.runrestore'), + ['backupFile' => $file], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(500); + $response->assertJsonPath('message', 'Gagal membuka file ZIP. Pastikan file tidak rusak.'); + + // Cleanup + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + }); +}); + +describe('restoreBackup - file SQL valid diterima', function () { + test('menerima file .sql dan memproses restore', function () { + // Buat file SQL sederhana — restore akan gagal karena mysql command, + // tapi kita hanya memverifikasi bahwa file diterima (tidak 422) + $sqlContent = "-- Test SQL dump\n-- Nothing to execute\n"; + $tmpFile = tempnam(sys_get_temp_dir(), 'test_') . '.sql'; + file_put_contents($tmpFile, $sqlContent); + + $file = new UploadedFile($tmpFile, 'test-backup.sql', 'text/plain', null, true); + + $response = $this->postJson( + route('setting.pengaturan-database.runrestore'), + ['backupFile' => $file], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + // Akan 500 karena mysql command tidak tersedia di test env, + // tapi yang penting bukan 422 (validasi ekstensi lolos) + expect($response->status())->not->toBe(422); + + // Cleanup + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + }); +}); diff --git a/tests/Unit/PengaturanDatabaseTest.php b/tests/Unit/PengaturanDatabaseTest.php new file mode 100644 index 0000000000..7319dda457 --- /dev/null +++ b/tests/Unit/PengaturanDatabaseTest.php @@ -0,0 +1,96 @@ +method = new \ReflectionMethod($controller, 'mapZipEntryToStoragePath'); + $this->method->setAccessible(true); + $this->controller = $controller; + }); + + test('memetakan path relative baru dengan benar', function () { + $result = $this->method->invoke($this->controller, 'storage/app/public/artikel/foto.jpg'); + + expect($result)->toBe('public/artikel/foto.jpg'); + }); + + test('memetakan path absolute Windows', function () { + $result = $this->method->invoke($this->controller, 'C:/laragon/www/OpenDK/storage/app/public/artikel/foto.jpg'); + + expect($result)->toBe('public/artikel/foto.jpg'); + }); + + test('memetakan path absolute Linux', function () { + $result = $this->method->invoke($this->controller, '/var/www/html/storage/app/public/artikel/foto.jpg'); + + expect($result)->toBe('public/artikel/foto.jpg'); + }); + + test('menangani path dengan backslash yang sudah dinormalkan', function () { + // Backslash normalization (str_replace('\\', '/')) dilakukan di restoreFromZip() + // sebelum memanggil method ini. Di sini kita menguji dengan path yang sudah dinormalkan. + $result = $this->method->invoke($this->controller, 'storage/app/public/foto.jpg'); + + expect($result)->toBe('public/foto.jpg'); + }); + + test('menolak path traversal', function () { + $entries = [ + 'storage/app/public/../../../etc/passwd', + 'storage/app/../../config/database.php', + ]; + + foreach ($entries as $entry) { + $result = $this->method->invoke($this->controller, $entry); + expect($result)->toBeNull(); + } + }); + + test('menolak entri db-dumps', function () { + $result = $this->method->invoke($this->controller, 'db-dumps/mysql-opendk.sql'); + + expect($result)->toBeNull(); + }); + + test('menolak direktori yang dikecualikan', function () { + $skipDirs = [ + 'storage/app/backup-storage/old-backup.zip', + 'storage/app/backup-temp/restore-dump.sql', + 'storage/app/framework/cache/data', + 'storage/app/logs/laravel.log', + 'storage/app/debugbar/trace.json', + ]; + + foreach ($skipDirs as $entry) { + $result = $this->method->invoke($this->controller, $entry); + expect($result)->toBeNull(); + } + }); + + test('menolak entri tanpa marker storage/app/', function () { + $entries = [ + 'vendor/laravel/framework/src/Illuminate/Foundation/Application.php', + 'composer.json', + 'app/Models/User.php', + ]; + + foreach ($entries as $entry) { + $result = $this->method->invoke($this->controller, $entry); + expect($result)->toBeNull(); + } + }); + + test('menolak path kosong setelah marker', function () { + $result = $this->method->invoke($this->controller, 'storage/app/'); + + expect($result)->toBeNull(); + }); + + test('menangani path dengan subdirektori dalam', function () { + $result = $this->method->invoke($this->controller, 'storage/app/public/artikel/2024/01/foto-artikel.jpg'); + + expect($result)->toBe('public/artikel/2024/01/foto-artikel.jpg'); + }); +}); From ede05826b5a2b6fa8ef40b733e6b008699058e52 Mon Sep 17 00:00:00 2001 From: Habibie Date: Thu, 16 Jul 2026 11:26:24 +0700 Subject: [PATCH 2/4] perbaiki sesuai review --- .../Setting/PengaturanDatabaseController.php | 112 +++++++++++++----- config/database.php | 2 +- .../table-restore.blade.php | 15 ++- .../PengaturanDatabaseRestoreTest.php | 95 +++++++++++++-- tests/Unit/PengaturanDatabaseTest.php | 40 +++++++ 5 files changed, 218 insertions(+), 46 deletions(-) diff --git a/app/Http/Controllers/Setting/PengaturanDatabaseController.php b/app/Http/Controllers/Setting/PengaturanDatabaseController.php index 4bcbb102e7..1969459067 100644 --- a/app/Http/Controllers/Setting/PengaturanDatabaseController.php +++ b/app/Http/Controllers/Setting/PengaturanDatabaseController.php @@ -15,7 +15,6 @@ use Illuminate\Support\Facades\File; use Exception; -use App\Http\Controllers\Setting\ZipArchive; class PengaturanDatabaseController extends Controller @@ -160,32 +159,31 @@ public function restoreBackup(Request $request) 'backupFile' => 'required|file', ]); - $allowedExtensions = ['sql', 'zip']; $file = $request->file('backupFile'); - $extension = strtolower($file->getClientOriginalExtension()); - if (! in_array($extension, $allowedExtensions)) { - return response()->json(['message' => 'File harus berupa .sql atau .zip'], 422); + + // Fix #4: Hanya terima .zip dari backup system — .sql tidak lagi didukung + if ($extension !== 'zip') { + return response()->json([ + 'success' => false, + 'message' => 'Hanya file .zip dari backup system yang diizinkan untuk restore.', + ], 422); } $fileUploadService = new \App\Services\FileUploadService(); - - if ($extension === 'zip') { - $allowedMimes = \App\Services\FileUploadService::getAllowedMimes('archive'); - $maxSize = 512000; // 500MB - } else { - $allowedMimes = ['application/octet-stream', 'text/plain', 'application/sql']; - $maxSize = 102400; // 100MB - } + $allowedMimes = \App\Services\FileUploadService::getAllowedMimes('archive'); + $maxSize = 512000; // 500MB $path = $fileUploadService->uploadSecure($file, 'backup-temp', $allowedMimes, $maxSize); $filename = basename($path); - $allowedChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-'; 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); + return response()->json([ + 'success' => false, + 'message' => 'Nama file tidak valid. Hanya boleh mengandung huruf (a-z/A-Z), angka (0-9), titik (.), dan garis bawah (_).', + ], 422); } $setDir = 'backup-temp'; @@ -193,24 +191,35 @@ public function restoreBackup(Request $request) try { Log::info('Starting restore process.'); - $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); + // Fix #3: Pre-restore backup otomatis (best-effort, tidak memblokir restore jika gagal) + try { + Log::info('Menjalankan pre-restore backup otomatis...'); + $backupExit = Artisan::call('backup:run'); + if ($backupExit !== 0) { + Log::warning('Pre-restore backup gagal (exit code: ' . $backupExit . '). Restore tetap dilanjutkan.'); + } else { + Log::info('Pre-restore backup berhasil.'); + } + } catch (\Exception $backupEx) { + Log::warning('Pre-restore backup exception: ' . $backupEx->getMessage() . '. Restore tetap dilanjutkan.'); } - Log::info('Normalized SQL file path from upload: ' . $finalPath); - $this->runRestoreDatabase($finalPath); + $finalPath = Storage::disk('public')->path($path); + + Log::info('Restore from ZIP: ' . $finalPath); + $result = $this->restoreFromZip($finalPath); - return response()->json(['message' => 'Database berhasil direstore.'], 200); + return response()->json([ + 'success' => true, + 'message' => "Restore berhasil. Database dan {$result['files_restored']} file asset telah dipulihkan.", + ], 200); } catch (\Exception $e) { Log::error('Restore error: ' . $e->getMessage()); - return response()->json(['message' => $e->getMessage()], 500); + + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 500); } finally { $this->deleteTemporaryDirectory(Storage::disk('public')->path($setDir)); Log::info('Direktori sementara berhasil dihapus.'); @@ -232,16 +241,16 @@ private function runRestoreDatabase($sqlFilePath) ? rtrim($binaryPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'mysql' : 'mysql'; - // Buat command untuk restore database + // Fix #1: Tambahkan escapeshellarg pada $sqlFilePath untuk mencegah shell injection $command = sprintf( - '%s -h%s -P%s -u%s %s %s < "%s" 2>&1', + '%s -h%s -P%s -u%s %s %s < %s 2>&1', escapeshellarg($mysqlBinary), escapeshellarg($dbHost), escapeshellarg($dbPort), escapeshellarg($dbUser), $dbPass !== '' ? '--password=' . escapeshellarg($dbPass) : '', escapeshellarg($dbName), - $sqlFilePath + escapeshellarg($sqlFilePath) ); exec($command, $output, $returnVar); @@ -273,6 +282,18 @@ private function restoreFromZip($zipFilePath) $tempSqlPath = $tempBase . '/restore-dump.sql'; try { + // Fix #6: Hitung jumlah db-dump entries sebelum proses — tolak jika > 1 + $dbDumpCount = 0; + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = str_replace('\\', '/', $zip->getNameIndex($i)); + if (str_starts_with($name, 'db-dumps/') && ! str_ends_with($name, '/')) { + $dbDumpCount++; + } + } + if ($dbDumpCount > 1) { + throw new \Exception("File ZIP mengandung {$dbDumpCount} database dump. Hanya 1 yang diizinkan."); + } + for ($i = 0; $i < $zip->numFiles; $i++) { $entry = $zip->getNameIndex($i); @@ -286,6 +307,13 @@ private function restoreFromZip($zipFilePath) // Database dump — ekstrak ke file terpisah untuk restore via mysql if (str_starts_with($normalized, 'db-dumps/')) { + // Fix #2: Validasi path traversal dan subdirektori sebelum extractTo() + $relativeToDbDumps = substr($normalized, strlen('db-dumps/')); + if (str_contains($relativeToDbDumps, '..') || str_contains($relativeToDbDumps, '/')) { + Log::warning('Skipping db-dumps entry with traversal or subdirectory: ' . $entry); + continue; + } + $zip->extractTo($tempBase, $entry); $extractedPath = $tempBase . '/' . $entry; if (file_exists($extractedPath)) { @@ -390,6 +418,28 @@ private function mapZipEntryToStoragePath($entry) private function deleteTemporaryDirectory($path) { + // Fix #7: Tolak symlink dan path di luar storage/ untuk mencegah penghapusan file penting + if (is_link($path)) { + Log::warning('deleteTemporaryDirectory: path adalah symlink, dibatalkan: ' . $path); + return false; + } + + $realPath = realpath($path); + $allowedBase = realpath(storage_path()); + + if ($realPath === false || $allowedBase === false) { + Log::warning('deleteTemporaryDirectory: path tidak dapat diresolve: ' . $path); + return false; + } + + // Pastikan path berada di dalam storage/ + $normalizedReal = str_replace('\\', '/', $realPath); + $normalizedBase = str_replace('\\', '/', $allowedBase); + if (! str_starts_with($normalizedReal, $normalizedBase)) { + Log::error('deleteTemporaryDirectory: path di luar storage/, dibatalkan: ' . $realPath); + return false; + } + if (is_dir($path)) { $files = array_diff(scandir($path), ['.', '..']); foreach ($files as $file) { diff --git a/config/database.php b/config/database.php index e04d5b1cc4..23846ada78 100644 --- a/config/database.php +++ b/config/database.php @@ -97,7 +97,7 @@ 'dump_binary_path' => env('DB_MYSQLDUMP_PATH'), // Sesuaikan dengan lokasi binary mysqldump di server // 'use_single_transaction' => true, // InnoDB // 'timeout' => 60, // Waktu timeout dalam detik - 'add_extra_option' => '--password=' . env('DB_PASSWORD', ''), + // 'add_extra_option' => '--password=' . env('DB_PASSWORD', ''), ], ], diff --git a/resources/views/setting/pengaturan_database/table-restore.blade.php b/resources/views/setting/pengaturan_database/table-restore.blade.php index 636222d85e..99190614e5 100644 --- a/resources/views/setting/pengaturan_database/table-restore.blade.php +++ b/resources/views/setting/pengaturan_database/table-restore.blade.php @@ -3,15 +3,15 @@
- -

Unggah file backup (.sql atau .zip)

+ +

Unggah file backup (.zip) yang dihasilkan oleh sistem backup

Informasi:

    -
  • File .sql — hanya memulihkan database
  • -
  • File .zip — memulihkan database + file asset (foto, dokumen, dll) dari backup spatie
  • +
  • Hanya file .zip dari backup system yang diterima
  • +
  • File .zip memulihkan database + file asset (foto, dokumen, dll) sekaligus
-

Peringatan: Restore file .zip akan menimpa file yang ada di storage. Pastikan data penting sudah dicadangkan.

+

Peringatan: Restore akan menimpa database dan file yang ada di storage. Pastikan data penting sudah dicadangkan sebelum melanjutkan.