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'); + }); +});