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:
@@ -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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|||||||
Reference in New Issue
Block a user