From f77fc725a8809e5cd228d76904eeee07d2eac61e Mon Sep 17 00:00:00 2001 From: GuavaPopper Date: Thu, 4 Jun 2026 14:51:07 +0700 Subject: [PATCH] feat: add scheduled daily backup, stored backup listing and download Co-Authored-By: Claude Sonnet 4.6 --- .../Commands/BackupDatabaseCommand.php | 70 +++++++++++++++++++ app/Http/Controllers/BackupController.php | 25 +++++++ docker/app/entrypoint.sh | 2 + resources/views/admin/users.blade.php | 43 +++++++++++- routes/console.php | 3 + routes/web.php | 5 +- 6 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 app/Console/Commands/BackupDatabaseCommand.php diff --git a/app/Console/Commands/BackupDatabaseCommand.php b/app/Console/Commands/BackupDatabaseCommand.php new file mode 100644 index 0000000..86dab8a --- /dev/null +++ b/app/Console/Commands/BackupDatabaseCommand.php @@ -0,0 +1,70 @@ +format('Ymd-His') . '.sql'; + $path = $dir . '/' . $filename; + + $process = new Process([ + 'mysqldump', + '-h', $db['host'], + '-P', (string) $db['port'], + '-u', $db['username'], + '--single-transaction', + '--skip-lock-tables', + '--no-tablespaces', + '--result-file=' . $path, + $db['database'], + ], null, ['MYSQL_PWD' => $db['password']]); + + $process->setTimeout(120); + $process->run(); + + if (!$process->isSuccessful()) { + $this->error('Backup gagal: ' . $process->getErrorOutput()); + ActivityLog::create([ + 'user_id' => null, + 'user_name' => 'Sistem', + 'action' => 'backup', + 'description' => 'Backup terjadwal GAGAL: ' . $process->getErrorOutput(), + 'ip_address' => '127.0.0.1', + ]); + return self::FAILURE; + } + + // Prune: hapus backup lebih dari 7 file terlama + $files = glob($dir . '/backup-webgis-*.sql'); + usort($files, fn($a, $b) => filemtime($b) - filemtime($a)); + foreach (array_slice($files, 7) as $old) { + unlink($old); + } + + $this->info("Backup disimpan: {$filename}"); + ActivityLog::create([ + 'user_id' => null, + 'user_name' => 'Sistem', + 'action' => 'backup', + 'description' => "Backup terjadwal berhasil: {$filename}", + 'ip_address' => '127.0.0.1', + ]); + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/BackupController.php b/app/Http/Controllers/BackupController.php index 36fc3f3..6fcb6b2 100644 --- a/app/Http/Controllers/BackupController.php +++ b/app/Http/Controllers/BackupController.php @@ -38,4 +38,29 @@ class BackupController extends Controller 'Content-Disposition' => "attachment; filename=\"{$filename}\"", ]); } + + public function listBackups() + { + $dir = storage_path('app/backups'); + $files = is_dir($dir) ? glob($dir . '/backup-webgis-*.sql') : []; + usort($files, fn($a, $b) => filemtime($b) - filemtime($a)); + + $list = array_map(fn($f) => [ + 'name' => basename($f), + 'size_kb' => round(filesize($f) / 1024, 1), + 'created_at' => date('d/m/Y H:i', filemtime($f)), + ], $files); + + return response()->json($list); + } + + public function downloadStored(string $filename) + { + if (!preg_match('/^backup-webgis-[\d-]+\.sql$/', $filename)) { + abort(400, 'Nama file tidak valid'); + } + $path = storage_path('app/backups/' . $filename); + if (!file_exists($path)) abort(404); + return response()->download($path); + } } diff --git a/docker/app/entrypoint.sh b/docker/app/entrypoint.sh index eecd175..465acc6 100644 --- a/docker/app/entrypoint.sh +++ b/docker/app/entrypoint.sh @@ -20,4 +20,6 @@ fi php artisan migrate --force --no-ansi php artisan db:seed --force --no-ansi +php artisan schedule:work --no-interaction > /dev/null 2>&1 & + exec "$@" diff --git a/resources/views/admin/users.blade.php b/resources/views/admin/users.blade.php index f71e3b9..b8df32e 100644 --- a/resources/views/admin/users.blade.php +++ b/resources/views/admin/users.blade.php @@ -126,6 +126,30 @@ + + +
+
+

+ Backup Tersimpan +

+ +
+
+ + + + + + + + + +
FileUkuranWaktu
Memuat...
+
+
@@ -259,7 +283,24 @@ return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } - document.addEventListener('DOMContentLoaded', loadUsers); + document.addEventListener('DOMContentLoaded', () => { loadUsers(); loadBackupList(); }); + + async function loadBackupList() { + try { + const res = await fetch('{{ url('/api/admin/backup/list') }}', { headers: { 'X-CSRF-TOKEN': csrfToken } }); + const list = await res.json(); + document.getElementById('bodyBackupList').innerHTML = list.length + ? list.map(b => ` + ${esc(b.name)} + ${esc(String(b.size_kb))} KB + ${esc(b.created_at)} + + `).join('') + : 'Belum ada backup tersimpan'; + } catch (e) { + showToast('Gagal memuat daftar backup', 'error'); + } + } async function loadUsers() { try { diff --git a/routes/console.php b/routes/console.php index 3c9adf1..56bfef8 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,10 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('backup:database')->daily()->appendOutputTo(storage_path('logs/backup.log')); diff --git a/routes/web.php b/routes/web.php index 7e9319d..733b71b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -68,7 +68,10 @@ Route::middleware('auth')->group(function () { Route::post('/import/ibadah', [ImportController::class, 'ibadah']); Route::post('/import/miskin', [ImportController::class, 'miskin']); - Route::get('/backup', [BackupController::class, 'download'])->name('admin.backup'); + Route::get('/backup', [BackupController::class, 'download'])->name('admin.backup'); + Route::get('/backup/list', [BackupController::class, 'listBackups']); + Route::get('/backup/download/{filename}', [BackupController::class, 'downloadStored']) + ->where('filename', '[^/]+'); Route::get('/logs', [ActivityLogController::class, 'index']); Route::get('/stats', [ActivityLogController::class, 'stats']);