feat: add database restore from uploaded SQL file with RESTORE confirmation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
GuavaPopper
2026-06-04 14:53:36 +07:00
parent 3ecf7d937c
commit b76284d8b7
3 changed files with 104 additions and 0 deletions
+31
View File
@@ -63,4 +63,35 @@ class BackupController extends Controller
if (!file_exists($path)) abort(404); if (!file_exists($path)) abort(404);
return response()->download($path); return response()->download($path);
} }
public function restore(\Illuminate\Http\Request $request)
{
$request->validate([
'file' => 'required|file|mimes:sql,txt|max:51200',
'confirm' => 'required|in:RESTORE',
]);
$db = config('database.connections.mysql');
$tmpPath = $request->file('file')->getRealPath();
$process = new Process([
'mysql',
'-h', $db['host'],
'-P', (string) $db['port'],
'-u', $db['username'],
$db['database'],
], null, ['MYSQL_PWD' => $db['password']]);
$process->setInput(file_get_contents($tmpPath));
$process->setTimeout(300);
$process->run();
if (!$process->isSuccessful()) {
ActivityLog::record('restore', 'Restore database GAGAL: ' . $process->getErrorOutput());
return response()->json(['message' => 'Restore gagal: ' . $process->getErrorOutput()], 500);
}
ActivityLog::record('restore', 'Restore database berhasil dari file yang diunggah');
return response()->json(['success' => true]);
}
} }
+72
View File
@@ -100,6 +100,9 @@
<a href="{{ url('/api/admin/backup') }}" class="btn btn-outline gap-2"> <a href="{{ url('/api/admin/backup') }}" class="btn btn-outline gap-2">
<i class="fa-solid fa-database text-success"></i> Backup Database <i class="fa-solid fa-database text-success"></i> Backup Database
</a> </a>
<button onclick="document.getElementById('modal_restore').showModal()" class="btn btn-outline gap-2 border-error text-error hover:bg-error hover:text-white">
<i class="fa-solid fa-upload"></i> Restore DB
</button>
<button onclick="openCreateModal()" class="btn btn-primary shadow-lg gap-2"> <button onclick="openCreateModal()" class="btn btn-primary shadow-lg gap-2">
<i class="fa-solid fa-user-plus"></i> Tambah Pengguna <i class="fa-solid fa-user-plus"></i> Tambah Pengguna
</button> </button>
@@ -498,6 +501,75 @@
document.getElementById('toastBox').appendChild(t); document.getElementById('toastBox').appendChild(t);
setTimeout(() => t.remove(), 3000); setTimeout(() => t.remove(), 3000);
} }
// ── RESTORE ─────────────────────────────────────────────────
function updateRestoreBtn() {
document.getElementById('btnRestoreSubmit').disabled =
document.getElementById('restore_confirm').value !== 'RESTORE';
}
async function submitRestore() {
const fileInput = document.getElementById('restore_file');
if (!fileInput.files.length) { showToast('Pilih file .sql terlebih dahulu', 'error'); return; }
const fd = new FormData();
fd.append('file', fileInput.files[0]);
fd.append('confirm', document.getElementById('restore_confirm').value);
const btn = document.getElementById('btnRestoreSubmit');
btn.disabled = true;
btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Memproses...';
const res = await fetch('{{ url('/api/admin/restore') }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken },
body: fd,
});
const json = await res.json();
document.getElementById('modal_restore').close();
document.getElementById('restore_confirm').value = '';
document.getElementById('restore_file').value = '';
btn.disabled = true;
btn.innerHTML = '<i class="fa-solid fa-triangle-exclamation mr-1"></i> Restore Sekarang';
if (json.success) {
showToast('Restore berhasil. Halaman akan dimuat ulang...', 'success');
setTimeout(() => location.reload(), 2000);
} else {
showToast(json.message || 'Restore gagal', 'error');
}
}
</script> </script>
<!-- Modal Restore -->
<dialog id="modal_restore" class="modal">
<div class="modal-box w-11/12 max-w-lg">
<h3 class="font-bold text-lg text-error mb-2">
<i class="fa-solid fa-triangle-exclamation mr-2"></i>Restore Database
</h3>
<div class="alert alert-error mb-4 text-sm">
<i class="fa-solid fa-circle-exclamation"></i>
<span><strong>PERINGATAN:</strong> Operasi ini akan <strong>menghapus seluruh data saat ini</strong> dan menggantinya dengan isi file backup. Tindakan ini <strong>tidak dapat dibatalkan.</strong></span>
</div>
<div class="form-control mb-3">
<label class="label"><span class="label-text font-bold">File Backup (.sql)</span></label>
<input id="restore_file" type="file" accept=".sql,.txt" class="file-input file-input-bordered file-input-error w-full">
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-bold">Ketik <code class="bg-base-200 px-1 rounded">RESTORE</code> untuk konfirmasi</span>
</label>
<input id="restore_confirm" type="text" placeholder="RESTORE" class="input input-bordered input-error w-full font-mono tracking-widest" oninput="updateRestoreBtn()">
</div>
<div class="modal-action">
<button onclick="document.getElementById('modal_restore').close()" class="btn btn-ghost">Batal</button>
<button id="btnRestoreSubmit" onclick="submitRestore()" class="btn btn-error text-white" disabled>
<i class="fa-solid fa-triangle-exclamation mr-1"></i> Restore Sekarang
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
</body> </body>
</html> </html>
+1
View File
@@ -85,6 +85,7 @@ Route::middleware('auth')->group(function () {
Route::get('/backup/list', [BackupController::class, 'listBackups']); Route::get('/backup/list', [BackupController::class, 'listBackups']);
Route::get('/backup/download/{filename}', [BackupController::class, 'downloadStored']) Route::get('/backup/download/{filename}', [BackupController::class, 'downloadStored'])
->where('filename', '[^/]+'); ->where('filename', '[^/]+');
Route::post('/restore', [BackupController::class, 'restore']);
Route::get('/logs', [ActivityLogController::class, 'index']); Route::get('/logs', [ActivityLogController::class, 'index']);
Route::get('/stats', [ActivityLogController::class, 'stats']); Route::get('/stats', [ActivityLogController::class, 'stats']);