configService = $configService; } /** * Apply database settings to Spatie Backup config at runtime. * * @param bool $skipNotifications Whether to skip setting up notifications (useful for manual UI backups) * @param string|null $driver Optional driver to override the database setting */ public function applyDynamicConfig(bool $skipNotifications = false, ?string $driver = null) { $settings = $this->configService->all(); // 1. Storage Disks $driver = $driver ?? get_setting('backup_db_driver', 'local'); Config::set('backup.backup.destination.disks', [$driver]); Config::set('backup.monitor_backups.0.disks', [$driver]); // Dynamic Cloud Storage Configuration if ($driver === 'gdrive') { Config::set('filesystems.disks.gdrive.clientId', $settings['gdrive_client_id'] ?? ''); Config::set('filesystems.disks.gdrive.clientSecret', $settings['gdrive_client_secret'] ?? ''); Config::set('filesystems.disks.gdrive.refreshToken', $settings['gdrive_refresh_token'] ?? ''); Config::set('filesystems.disks.gdrive.folder', $settings['gdrive_folder'] ?? 'LaravelBackups'); } // 2. Cleanup Policy $retention = (int) ($settings['backup_db_retention'] ?? 7); Config::set('backup.cleanup.default_strategy.keep_all_backups_for_days', $retention); // 3. Encryption if (! empty($settings['backup_db_encrypt']) && ! empty($settings['backup_db_encrypt_key'])) { Config::set('backup.backup.password', $settings['backup_db_encrypt_key']); Config::set('backup.backup.encryption', 'aes256'); } else { Config::set('backup.backup.password', null); Config::set('backup.backup.encryption', 'none'); } // 4. Notifications $notifyOn = $settings['backup_db_notify_on'] ?? 'failed'; $notifyTo = $settings['backup_db_notify_to'] ?? ''; $channels = []; if (! empty($notifyTo)) { if (filter_var($notifyTo, FILTER_VALIDATE_EMAIL)) { $channels[] = 'mail'; Config::set('backup.notifications.mail.to', $notifyTo); } elseif (str_starts_with($notifyTo, 'http')) { $channels[] = 'webhook'; Config::set('backup.notifications.notifications.webhook.url', $notifyTo); } } if ($skipNotifications || $notifyOn === 'none') { // Use empty channel arrays per-class (Spatie requires all keys to exist) $allClasses = [ BackupHasFailedNotification::class, UnhealthyBackupWasFoundNotification::class, CleanupHasFailedNotification::class, BackupWasSuccessfulNotification::class, HealthyBackupWasFoundNotification::class, CleanupWasSuccessfulNotification::class, ]; Config::set('backup.notifications.notifications', array_fill_keys($allClasses, [])); } else { $map = [ 'success' => [BackupWasSuccessfulNotification::class], 'failed' => [BackupHasFailedNotification::class], 'both' => [ BackupWasSuccessfulNotification::class, BackupHasFailedNotification::class, ], ]; $classes = $map[$notifyOn] ?? $map['failed']; $newNotifs = []; foreach ($classes as $class) { $newNotifs[$class] = $channels; } Config::set('backup.notifications.notifications', $newNotifs); } // 5. Exclude Tables (Injected via database config) $dbDriver = config('database.default'); if (! empty($settings['backup_db_exclude'])) { $excluded = array_map('trim', explode(',', $settings['backup_db_exclude'])); Config::set("database.connections.{$dbDriver}.dump.exclude_tables", $excluded); } // 6. DB-Specific Options (mysqldump / pg_dump) if ($dbDriver === 'mysql') { $extraOptions = ['--hex-blob']; Config::set('database.connections.mysql.dump.add_extra_option', implode(' ', $extraOptions)); // Windows/Laragon mysqldump path fix if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { $candidates = ['C:/laragon/bin/mysql/mysql-8.0.30-winx64/bin']; $globMatches = glob('C:/laragon/bin/mysql/*/bin', GLOB_ONLYDIR); if ($globMatches) { $candidates = array_merge($candidates, $globMatches); } foreach ($candidates as $candidate) { if (is_dir($candidate) && file_exists($candidate.'/mysqldump.exe')) { Config::set('database.connections.mysql.dump.dump_binary_path', $candidate); break; } } } } elseif ($dbDriver === 'pgsql') { // Postgres specific dump options // Spatie pg_dump usually works well with defaults, but we can add path if needed if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { $globMatches = glob('C:/laragon/bin/postgresql/*/bin', GLOB_ONLYDIR); if ($globMatches) { Config::set('database.connections.pgsql.dump.dump_binary_path', end($globMatches)); } } } } public function getBackupList(bool $forceConfig = true, bool $forceRefresh = false) { if ($forceConfig) { $this->applyDynamicConfig(true); // Don't need notifications just to list files } $cacheKey = 'system.backup_list'; // Helper function for the scan logic $scan = function () { $config = SpatieConfig::fromArray(config('backup')); $backupDestinations = BackupDestinationFactory::createFromArray($config); $backups = []; foreach ($backupDestinations as $destination) { foreach ($destination->backups() as $backup) { $backups[] = [ 'name' => $backup->path(), 'size' => $this->formatBytes($backup->sizeInBytes()), 'storage' => $destination->diskName(), 'date' => $backup->date()->format('Y-m-d H:i:s'), 'timestamp' => $backup->date()->timestamp, 'status' => 'Success', ]; } } // Sort by date descending usort($backups, fn ($a, $b) => $b['timestamp'] <=> $a['timestamp']); return $backups; }; if ($forceRefresh) { $data = $scan(); Cache::put($cacheKey, $data, 30); return $data; } return Cache::remember($cacheKey, 30, $scan); } /** * Get storage health statistics. */ public function getStorageStats(?string $driver = null) { $this->applyDynamicConfig(true, $driver); $driver = $driver ?? config('backup.backup.destination.disks.0', 'local'); $total = 0; $free = 0; $used = 0; $label = 'Local Disk'; try { if ($driver === 'local') { $total = @disk_total_space(storage_path('app')) ?: 0; $free = @disk_free_space(storage_path('app')) ?: 0; $used = $total - $free; } else { // For cloud, we might not get total/free easily depending on API // We'll return just the used size and assume a default total for UI progress (e.g., 15GB for GDrive free) $backups = $this->getBackupList(forceRefresh: false); foreach ($backups as $b) { if ($b['storage'] === $driver) { $used += $this->parseBytes($b['size']); } } $label = strtoupper($driver); // Estimate total for progress bar visibility $total = $driver === 'gdrive' ? (15 * 1024 * 1024 * 1024) : 0; $free = max(0, $total - $used); } } catch (Exception $e) { Log::error('Failed to get storage stats: '.$e->getMessage()); } return [ 'label' => $label, 'driver' => $driver, 'total' => $this->formatBytes($total), 'free' => $this->formatBytes($free), 'used' => $this->formatBytes($used), 'percentage' => $total > 0 ? round(($used / $total) * 100, 1) : 0, 'health' => ($total > 0 && ($free / $total) < 0.1) ? 'danger' : 'success', 'requirements' => $this->checkRequirements(), ]; } /** * Check if system requirements (binaries) for current driver are met. */ public function checkRequirements() { $dbDriver = config('database.default'); $binary = $dbDriver === 'pgsql' ? 'pg_dump' : ($dbDriver === 'mysql' ? 'mysqldump' : null); if (! $binary) { return ['status' => true]; } $process = new Process(['which', $binary]); $process->run(); $missing = ! $process->isSuccessful(); return [ 'status' => ! $missing, 'binary' => $binary, 'message' => $missing ? __(':bin not found on server. Please install it to enable backups.', ['bin' => $binary]) : null, ]; } /** * Test connection to a specific storage disk. */ public function testConnection() { $this->applyDynamicConfig(true); $disk = config('backup.backup.destination.disks.0', 'local'); try { $backupName = config('backup.backup.name', 'biiproject'); // Pre-create directory for Google Drive to avoid reachability check failure if ($disk === 'gdrive' && ! Storage::disk($disk)->exists($backupName)) { Storage::disk($disk)->makeDirectory($backupName); } Storage::disk($disk)->files($backupName); return [ 'success' => true, 'message' => __('Successfully connected to :disk storage.', ['disk' => strtoupper($disk)]), ]; } catch (Exception $e) { return [ 'success' => false, 'message' => __('Connection failed: ').$e->getMessage(), ]; } } private function parseBytes(string $value): float { return MonitoringFormatter::parseBytes($value); } /** * Create a new backup. * * Uses a child process instead of Artisan::call() to avoid inheriting the * broken TCP/IP socket context from php artisan serve on Windows. */ public function createBackup() { $phpBinary = PHP_BINARY; $artisan = base_path('artisan'); // Ensure dynamic config is applied in the current process $this->applyDynamicConfig(true); $disk = config('backup.backup.destination.disks.0', 'local'); $backupName = config('backup.backup.name', 'biiproject'); // Pre-create directory for Google Drive to avoid reachability check failure in child process if ($disk === 'gdrive') { try { if (! Storage::disk($disk)->exists($backupName)) { Storage::disk($disk)->makeDirectory($backupName); } } catch (Exception $e) { Log::warning('Failed to pre-create GDrive backup directory: '.$e->getMessage()); } } $dbDriver = config('database.default'); $binaryPath = config("database.connections.{$dbDriver}.dump.dump_binary_path"); $env = getenv(); // Prepend binary path to PATH for child process if ($binaryPath) { $separator = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN' ? ';' : ':'; $env['PATH'] = $binaryPath.$separator.($env['PATH'] ?? ''); } // Add some basic required env vars if missing $env['APP_ENV'] = app()->environment(); $env['APP_KEY'] = config('app.key'); $command = array_filter([ $phpBinary, $artisan, 'backup:run', '--only-db', '--disable-notifications', ]); $process = new Process($command, base_path(), $env); $process->setTimeout(300); // 5 minutes max Log::info('Backup: starting subprocess — '.implode(' ', $command)); $process->run(); $stdout = $process->getOutput(); $stderr = $process->getErrorOutput(); Log::info('Backup stdout: '.trim($stdout)); if ($stderr) { Log::warning('Backup stderr: '.trim($stderr)); } if (! $process->isSuccessful()) { $detail = trim($stderr ?: $stdout ?: 'No output captured.'); Notification::send( User::permission('view notification center')->get(), new SystemManagementNotification('Backup Failed', "Database backup failed: {$detail}", 'warning', 'Developer') ); throw new Exception( "Backup failed (exit {$process->getExitCode()}): {$detail}" ); } Notification::send( User::permission('view notification center')->get(), new SystemManagementNotification('Backup Successful', 'Automated database backup completed successfully.', 'info', 'Developer') ); // Bust the cache AFTER the file is confirmed written to disk Cache::forget('system.backup_list'); return $stdout; } /** * Restore from a backup file. */ public function restoreBackup($disk, $path) { $this->applyDynamicConfig(); if (! Storage::disk($disk)->exists($path)) { throw new Exception("Backup file not found on disk: {$disk}"); } // 1. Enable Maintenance Mode to prevent data corruption Artisan::call('down', [ '--refresh' => 15, '--secret' => 'restore-mode', '--render' => 'errors::503', ]); try { // 2. Download/Copy to temp via Stream (Safer for large files) $tempPath = storage_path('app/temp_restore.zip'); $diskStream = Storage::disk($disk)->readStream($path); $localStream = fopen($tempPath, 'w+'); if (! $diskStream || ! $localStream) { throw new Exception('Failed to open streams for restoration.'); } stream_copy_to_stream($diskStream, $localStream); fclose($localStream); if (is_resource($diskStream)) { fclose($diskStream); } // 3. Extract SQL from Zip (with Zip Slip protection) $zip = new \ZipArchive; $sqlFile = null; $extractBase = realpath(storage_path('app')); if ($zip->open($tempPath) === true) { for ($i = 0; $i < $zip->numFiles; $i++) { $filename = $zip->getNameIndex($i); if (! str_ends_with($filename, '.sql')) { continue; } // Prevent path traversal $resolvedPath = $extractBase.DIRECTORY_SEPARATOR.ltrim($filename, '/\\'); $realResolved = realpath(dirname($resolvedPath)); if ($realResolved === false || ! str_starts_with($realResolved, $extractBase)) { $zip->close(); throw new Exception("Invalid path in backup archive: {$filename}"); } $zip->extractTo($extractBase, $filename); $sqlFile = $extractBase.DIRECTORY_SEPARATOR.$filename; break; } $zip->close(); } if (! $sqlFile || ! file_exists($sqlFile)) { throw new Exception('Could not find SQL file in the backup archive.'); } // 4. Execute Restore using array-based Process with Streamed Input $dbDriver = config('database.default'); $dbConfig = config("database.connections.{$dbDriver}"); $binPath = config("database.connections.{$dbDriver}.dump.dump_binary_path", ''); $env = array_merge(getenv(), [ 'SystemRoot' => getenv('SystemRoot') ?: 'C:\WINDOWS', 'SystemDrive' => getenv('SystemDrive') ?: 'C:', 'windir' => getenv('windir') ?: 'C:\WINDOWS', ]); if ($dbDriver === 'mysql') { $command = [ $binPath ? rtrim($binPath, '/\\').DIRECTORY_SEPARATOR.'mysql' : 'mysql', '--host='.($dbConfig['host'] ?? '127.0.0.1'), '--port='.($dbConfig['port'] ?? '3306'), '--user='.($dbConfig['username'] ?? ''), '--password='.($dbConfig['password'] ?? ''), $dbConfig['database'] ?? '', ]; } elseif ($dbDriver === 'pgsql') { $command = [ $binPath ? rtrim($binPath, '/\\').DIRECTORY_SEPARATOR.'psql' : 'psql', '--host='.($dbConfig['host'] ?? '127.0.0.1'), '--port='.($dbConfig['port'] ?? '5432'), '--username='.($dbConfig['username'] ?? 'postgres'), '--dbname='.($dbConfig['database'] ?? ''), ]; $env['PGPASSWORD'] = $dbConfig['password'] ?? ''; } else { throw new Exception('Restore currently only supports mysql or pgsql drivers.'); } // High-performance streamed restore: open SQL file and pass the handle to Process $sqlHandle = fopen($sqlFile, 'r'); $process = new Process($command, base_path(), $env, $sqlHandle); $process->setTimeout(1200); // 20 minutes for very large DBs $process->run(); if (is_resource($sqlHandle)) { fclose($sqlHandle); } // Cleanup temp files @unlink($tempPath); @unlink($sqlFile); if (! $process->isSuccessful()) { throw new Exception('Restore execution failed: '.($process->getErrorOutput() ?: $process->getOutput() ?: 'Check logs.')); } // 5. Clear Caches & Optimization Artisan::call('optimize:clear'); return true; } catch (Exception $e) { throw $e; } finally { // 6. Ensure system comes back UP even if restore failed Artisan::call('up'); } } protected function formatBytes(int|float $bytes, int $precision = 2): string { return MonitoringFormatter::bytes($bytes, $precision); } }