diff --git a/app/Console/Commands/BackupCommand.php b/app/Console/Commands/BackupCommand.php new file mode 100644 index 0000000..55bafd6 --- /dev/null +++ b/app/Console/Commands/BackupCommand.php @@ -0,0 +1,357 @@ +error("Cannot create backup directory: {$dir}"); + + return self::FAILURE; + } + + $stamp = now()->format('Ymd-His'); + $failed = false; + + if (! $this->option('media-only')) { + $failed = ! $this->backupDatabase($dir, $stamp) || $failed; + } + + if ($this->option('media') || $this->option('media-only')) { + $failed = ! $this->mirrorMedia() || $failed; + } else { + $this->line(' – media not included (pass --media to mirror it)'); + } + + $this->shipOffServer(); + $this->prune($dir); + + if ($failed) { + Log::error('Backup run failed', ['artifacts' => $this->artifacts]); + $this->error('Backup FAILED — see the errors above.'); + + return self::FAILURE; + } + + Log::info('Backup completed', ['artifacts' => array_map('basename', $this->artifacts)]); + $this->newLine(); + $this->info('Backup complete.'); + + return self::SUCCESS; + } + + /* ---------------- Database ---------------- */ + + private function backupDatabase(string $dir, string $stamp): bool + { + $connection = config('database.default'); + $driver = config("database.connections.{$connection}.driver"); + + return match ($driver) { + 'sqlite' => $this->backupSqlite($dir, $stamp), + 'mysql', 'mariadb' => $this->backupMysql($dir, $stamp, $connection), + default => tap(false, fn () => $this->error("Unsupported database driver for backup: {$driver}")), + }; + } + + /** Consistent SQLite snapshot: checkpoint the WAL, then VACUUM INTO. */ + private function backupSqlite(string $dir, string $stamp): bool + { + $target = $dir."/db-{$stamp}.sqlite"; + + // VACUUM cannot run inside a transaction. Rather than let SQLite throw + // something cryptic, say so plainly — a backup that quietly produces + // nothing is the worst possible outcome. + if (DB::transactionLevel() > 0) { + $this->error('Cannot snapshot the database inside an open transaction.'); + + return false; + } + + try { + // Harmless when the database is not in WAL mode. + DB::statement('PRAGMA wal_checkpoint(TRUNCATE)'); + DB::statement('VACUUM INTO '.DB::getPdo()->quote($target)); + } catch (\Throwable $e) { + $this->error('Database snapshot failed: '.$e->getMessage()); + + return false; + } + + if (! $this->verifySqlite($target)) { + return false; + } + + $this->artifacts[] = $target; + $this->line(sprintf(' ✓ database %s (%s)', basename($target), $this->humanSize($target))); + + return true; + } + + /** + * Read the snapshot back. A file that exists but cannot be opened, or that + * holds no migrations and no videos, is not a backup worth trusting. + */ + private function verifySqlite(string $path): bool + { + try { + $pdo = new PDO('sqlite:'.$path); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + if ($pdo->query('PRAGMA integrity_check')->fetchColumn() !== 'ok') { + $this->error("Snapshot failed integrity check: {$path}"); + + return false; + } + + $migrations = (int) $pdo->query('SELECT count(*) FROM migrations')->fetchColumn(); + + if ($migrations < 1) { + $this->error("Snapshot contains no migrations — refusing to trust it: {$path}"); + + return false; + } + + // Read a real application table too. `migrations` alone would still + // be present in a snapshot taken of an empty database. + $videos = (int) $pdo->query('SELECT count(*) FROM videos')->fetchColumn(); + $this->line(" ✓ verified integrity ok · {$migrations} migrations · {$videos} videos"); + } catch (\Throwable $e) { + $this->error('Snapshot could not be verified: '.$e->getMessage()); + + return false; + } + + return true; + } + + private function backupMysql(string $dir, string $stamp, string $connection): bool + { + $target = $dir."/db-{$stamp}.sql.gz"; + $c = config("database.connections.{$connection}"); + + $command = sprintf( + 'mysqldump --single-transaction --quick --no-tablespaces -h%s -P%s -u%s %s %s | gzip > %s', + escapeshellarg((string) $c['host']), + escapeshellarg((string) $c['port']), + escapeshellarg((string) $c['username']), + $c['password'] ? '-p'.escapeshellarg((string) $c['password']) : '', + escapeshellarg((string) $c['database']), + escapeshellarg($target), + ); + + exec($command, $out, $status); + + if ($status !== 0 || ! is_file($target) || filesize($target) === 0) { + $this->error('mysqldump failed (exit '.$status.').'); + + return false; + } + + // Verify the dump is readable and non-trivial rather than trusting the + // exit code — a gzip of an error message also exits 0. + exec('gzip -t '.escapeshellarg($target).' 2>/dev/null', $o2, $verify); + + if ($verify !== 0) { + $this->error('Dump failed its gzip integrity check — discarding.'); + @unlink($target); + + return false; + } + + $this->artifacts[] = $target; + $this->line(sprintf(' ✓ database %s (%s)', basename($target), $this->humanSize($target))); + + return true; + } + + /* ---------------- Media ---------------- */ + + /** + * Mirror the media library with rsync. + * + * Uploaded video cannot be regenerated from anything — losing it is the + * one unrecoverable outcome on this platform. rsync is used rather than an + * archive because the library is large and almost entirely immutable, so + * each run copies only what is new. + */ + private function mirrorMedia(): bool + { + $destination = (string) config('backup.media_destination'); + + if ($destination === '') { + $this->newLine(); + $this->warn('--media was requested but BACKUP_MEDIA_DEST is not set — media was NOT backed up.'); + $this->line(' Uploaded video cannot be regenerated. Point BACKUP_MEDIA_DEST at a NAS mount or remote path.'); + + return false; + } + + if (! is_dir($destination) && ! @mkdir($destination, 0775, true)) { + $this->error("Media destination is not reachable: {$destination}"); + + return false; + } + + $paths = array_values(array_filter((array) config('backup.media_paths', []), 'is_dir')); + + if (! $paths) { + $this->line(' – media nothing to mirror'); + + return true; + } + + // --delete is opt-in and off by default: a mirror that deletes is one + // wrong path away from erasing the copy it exists to protect. + $flags = '-a --partial --info=stats2'; + + if (config('backup.media_delete')) { + $flags .= ' --delete'; + } + + $ok = true; + + foreach ($paths as $path) { + // Trailing slash on the source, basename on the destination, so + // each folder lands in its own directory rather than merging. + $command = sprintf( + 'rsync %s %s %s 2>&1', + $flags, + escapeshellarg(rtrim($path, '/').'/'), + escapeshellarg(rtrim($destination, '/').'/'.basename($path).'/'), + ); + + exec($command, $out, $status); + + if ($status !== 0) { + $this->error('Media mirror failed for '.basename($path).' (exit '.$status.').'); + $ok = false; + + continue; + } + + $this->line(sprintf(' ✓ media %s → %s', basename($path), $destination)); + } + + return $ok; + } + + /* ---------------- Off-server ---------------- */ + + private function shipOffServer(): void + { + $disk = config('backup.disk'); + + if (! $disk) { + $this->newLine(); + $this->warn('No BACKUP_DISK configured — these copies live on the SAME disk as the data.'); + $this->line(' They survive a bad migration, but not a dead server. Set BACKUP_DISK to a remote filesystem.'); + + return; + } + + foreach ($this->artifacts as $artifact) { + try { + Storage::disk($disk)->put('backups/'.basename($artifact), fopen($artifact, 'rb')); + $this->line(" ✓ off-server {$disk}:backups/".basename($artifact)); + + if (! $this->option('keep-local')) { + @unlink($artifact); + } + } catch (\Throwable $e) { + // Never delete the local copy when the upload failed — it is + // the only copy left. + $this->error('Off-server upload failed for '.basename($artifact).': '.$e->getMessage()); + } + } + } + + /* ---------------- Rotation ---------------- */ + + /** + * Delete artifacts past the retention window, but always keep the newest + * few — a box that sat idle for a month must still have something to + * restore from. + */ + private function prune(string $dir): void + { + $days = (int) config('backup.retain_days', 14); + + if ($days <= 0) { + return; + } + + $keep = max(0, (int) config('backup.keep_minimum', 3)); + $cutoff = now()->subDays($days)->getTimestamp(); + $removed = 0; + + $files = glob($dir.'/db-*') ?: []; + usort($files, fn ($a, $b) => filemtime($b) <=> filemtime($a)); // newest first + + foreach (array_slice($files, $keep) as $file) { + if (filemtime($file) < $cutoff && @unlink($file)) { + $removed++; + } + } + + if ($removed) { + $this->line(" ✓ pruned {$removed} artifact(s) older than {$days} days"); + } + } + + private function humanSize(string $path): string + { + $bytes = (int) @filesize($path); + $units = ['B', 'KB', 'MB', 'GB']; + + for ($i = 0; $bytes >= 1024 && $i < count($units) - 1; $i++) { + $bytes /= 1024; + } + + return round($bytes, 1).' '.$units[$i]; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index a0af2e9..52452b3 100755 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -48,6 +48,14 @@ class Kernel extends ConsoleKernel ->weeklyOn(1, '09:00') ->withoutOverlapping() ->runInBackground(); + + // Nightly database backup at 03:30 Asia/Bahrain. Writes to + // data/backups/db-YYYYMMDD-HHMMSS.sqlite, verifies the snapshot, + // and (when BACKUP_DISK is set) ships a copy off-server. + $schedule->command('takeone:backup') + ->dailyAt('03:30') + ->withoutOverlapping() + ->runInBackground(); } /** diff --git a/config/backup.php b/config/backup.php new file mode 100644 index 0000000..060fa4d --- /dev/null +++ b/config/backup.php @@ -0,0 +1,74 @@ + env('BACKUP_PATH', base_path('data/backups')), + + /* + |-------------------------------------------------------------------------- + | Retention + |-------------------------------------------------------------------------- + | + | Artifacts older than retain_days are pruned, but the newest keep_minimum + | are ALWAYS kept regardless of age — a box that sat idle for a month must + | still have something to restore from. + | + */ + 'retain_days' => (int) env('BACKUP_RETAIN_DAYS', 14), + 'keep_minimum' => (int) env('BACKUP_KEEP_MINIMUM', 3), + + /* + |-------------------------------------------------------------------------- + | Off-server copy + |-------------------------------------------------------------------------- + | + | A filesystems.php disk name. A snapshot sitting on the same disk as the + | data survives a bad migration but not a dead disk, so set this to a + | remote disk. The command warns on every run while it is unset. + | + */ + 'disk' => env('BACKUP_DISK'), + + /* + |-------------------------------------------------------------------------- + | Media library + |-------------------------------------------------------------------------- + | + | Uploaded video is the irreplaceable asset here — it cannot be regenerated + | from anything. It is mirrored with rsync (incremental, resumable) to + | media_destination, which should be a NAS mount or another machine. + | + | Only runs when --media is passed AND a destination is set. + | + | media_delete stays false by default: a mirror that deletes is one bad + | path away from erasing the copy it was meant to protect. + | + */ + 'media_paths' => [ + base_path('data/app/users'), + base_path('data/app/public'), + ], + + 'media_destination' => env('BACKUP_MEDIA_DEST'), + + 'media_delete' => (bool) env('BACKUP_MEDIA_DELETE', false), + +];