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]; } }