diff --git a/.gitignore b/.gitignore index 613a645e4d..5f90007354 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,7 @@ mcp.json .env.testing nul -graphify-out \ No newline at end of file + +# Agent +/.qwen +graphify-out diff --git a/app/Http/Controllers/Setting/PengaturanDatabaseController.php b/app/Http/Controllers/Setting/PengaturanDatabaseController.php index 230d676978..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 @@ -72,16 +71,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,55 +159,69 @@ public function restoreBackup(Request $request) 'backupFile' => 'required|file', ]); - // Validasi tipe file - $allowedExtensions = ['sql']; $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()); + + // 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(); - - // 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 - + $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); + if (! preg_match("/^[" . $allowedChars . "]+$/", $filename)) { + 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); } - // 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"); + // 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.'); + } + + $finalPath = Storage::disk('public')->path($path); - // Jalankan proses restore - $this->runRestoreDatabase($finalPath); + 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 { - // Hapus file sementara - $this->deleteTemporaryDirectory(Storage::path($setDir)); + $this->deleteTemporaryDirectory(Storage::disk('public')->path($setDir)); Log::info('Direktori sementara berhasil dihapus.'); } } @@ -210,19 +235,24 @@ private function runRestoreDatabase($sqlFilePath) $dbUser = config('database.connections.mysql.username'); $dbPass = config('database.connections.mysql.password'); - // Buat command untuk restore database + // 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'; + + // Fix #1: Tambahkan escapeshellarg pada $sqlFilePath untuk mencegah shell injection $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 + escapeshellarg($sqlFilePath) ); - // $this->info("Executing command: $command"); - exec($command, $output, $returnVar); // cek hasil @@ -234,9 +264,182 @@ 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 { + // 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); + + // 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/')) { + // 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)) { + 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) { + // 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/catatan_rilis.md b/catatan_rilis.md index 31a687254b..0656fc6de1 100644 --- a/catatan_rilis.md +++ b/catatan_rilis.md @@ -5,28 +5,10 @@ Terimakasih [isi disini] yang telah berkontribusi langsung mengembangkan aplikas #### FITUR -1. [#1624](https://github.com/OpenSID/OpenDK/issues/1624) Penyesuaian tombol agar fitur tambah prosedur lebih jelas & informatif. -2. [#1626](https://github.com/OpenSID/OpenDK/issues/1626) Penyesuaian tombol agar fitur potensi lebih jelas & informatif. -3. [#1645](https://github.com/OpenSID/OpenDK/issues/1645) Penyesuaian artikel desa untuk sinkronisasi API Satu Data di ubah menggunakan API. - +1. [#1616](https://github.com/OpenSID/OpenDK/issues/1616) Tambah fitur backup dan restore asset storage. #### BUG -1. [#1668](https://github.com/OpenSID/OpenDK/issues/1668) Perbaikan feed untuk database gabungan yang tidak tampil. -2. [#1669](https://github.com/OpenSID/OpenDK/issues/1669) Perbaikan foto pengurus dan media terkait tidak tampil. -3. [#1629](https://github.com/OpenSID/OpenDK/issues/1629) Perbaikan error pada statistik/anggaran-desa. -4. [#1634](https://github.com/OpenSID/OpenDK/issues/1634) Perbaiki menu aktif harus terbuka dan terlihat pada sidebar. -5. [#1622](https://github.com/OpenSID/OpenDK/issues/1622) Perbaiki tab agama pada statistik kependudukan tidak berfungsi. -6. [#1636](https://github.com/OpenSID/OpenDK/issues/1636) Perbaiki struktur organisasi. - - #### TEKNIS -1. [#1619](https://github.com/OpenSID/OpenDK/issues/1619) Perbaikan teknis terkait upload tema di OpenDK. -2. [#1630](https://github.com/OpenSID/OpenDK/issues/1630) Sesuaikan perubahan tombol aksi agar konsisten. -3. [#1632](https://github.com/OpenSID/OpenDK/issues/1632) Penyesuaian pendaftaran kerjasama. -4. [#1635](https://github.com/OpenSID/OpenDK/issues/1635) Penyesuaian alert agar konsisten. -5. [#1627](https://github.com/OpenSID/OpenDK/issues/1627) Penyesuaian UI/UX di modul event. -6. [#1628](https://github.com/OpenSID/OpenDK/issues/1628) Penyesuaian UI/UX di modul artikel. -7. [#1650](https://github.com/OpenSID/OpenDK/issues/1650) Penyesuaian menu seeder yang tidak memiliki sumber. \ No newline at end of file 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/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-backup.blade.php b/resources/views/setting/pengaturan_database/table-backup.blade.php index 96a66650cc..3aa1829fc0 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..0f47cffd22 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')