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),
+
+];