label(__('Start Over')) ->icon('heroicon-o-arrow-path') ->color('gray') ->requiresConfirmation() ->modalHeading(__('Start Over')) ->modalDescription(__('This will reset all migration data. Are you sure?')) ->action('resetMigration'), ]; } public function mount(): void { // Restore credentials from session if page was reloaded (e.g., after auto-advance) $this->restoreCredentialsFromSession(); $this->restoreMigrationStateFromSession(); } public function updatedHostname(): void { $this->resetConnection(); } public function updatedCpanelUsername(): void { $this->resetConnection(); } public function updatedApiToken(): void { $this->resetConnection(); } public function updatedPort(): void { $this->resetConnection(); } public function updatedUseSSL(): void { $this->resetConnection(); } protected function resetConnection(): void { $this->cpanel = null; $this->isConnected = false; $this->connectionInfo = []; } public function getAgent(): AgentClient { return $this->agent ??= new AgentClient; } protected function getTargetUser(): ?User { if ($this->userMode === 'existing') { return $this->targetUserId ? User::find($this->targetUserId) : null; } // 'create' mode - return null here, user will be created in startRestore() return null; } /** * Create a new user from the cPanel backup. */ protected function createUserFromBackup(): ?User { if (empty($this->cpanelUsername)) { $this->addLog(__('No cPanel username available'), 'error'); return null; } // Check if panel user already exists $existingUser = User::where('username', $this->cpanelUsername)->first(); if ($existingUser) { $this->addLog(__('User :username already exists, using existing user', ['username' => $this->cpanelUsername]), 'info'); $this->targetUserId = $existingUser->id; return $existingUser; } // Get the main domain from discovered data for email $mainDomain = null; foreach ($this->discoveredData['domains'] ?? [] as $domain) { if (($domain['type'] ?? '') === 'main') { $mainDomain = $domain['name'] ?? null; break; } } // Fallback to first domain or generate placeholder if (! $mainDomain && ! empty($this->discoveredData['domains'])) { $mainDomain = $this->discoveredData['domains'][0]['name'] ?? null; } $emailDomain = $mainDomain ?? 'example.com'; $userEmail = $this->cpanelUsername.'@'.$emailDomain; // Check if email already exists (must be unique) if (User::where('email', $userEmail)->exists()) { $userEmail = $this->cpanelUsername.'.'.time().'@'.$emailDomain; } // Generate a secure random password $password = bin2hex(random_bytes(12)); try { // Check if Linux user already exists exec('id '.escapeshellarg($this->cpanelUsername).' 2>/dev/null', $output, $exitCode); $linuxUserExists = ($exitCode === 0); if (! $linuxUserExists) { // Create Linux user via agent $this->addLog(__('Creating system user: :username', ['username' => $this->cpanelUsername]), 'pending'); $result = $this->getAgent()->send('user.create', [ 'username' => $this->cpanelUsername, 'password' => $password, ]); if (! ($result['success'] ?? false)) { throw new Exception($result['error'] ?? __('Failed to create system user')); } $this->addLog(__('System user created: :username', ['username' => $this->cpanelUsername]), 'success'); } else { $this->addLog(__('System user already exists: :username', ['username' => $this->cpanelUsername]), 'info'); } // Create panel user record $user = User::create([ 'name' => ucfirst($this->cpanelUsername), 'username' => $this->cpanelUsername, 'email' => $userEmail, 'password' => Hash::make($password), 'home_directory' => '/home/'.$this->cpanelUsername, 'disk_quota_mb' => null, // Unlimited 'is_active' => true, 'is_admin' => false, ]); $this->targetUserId = $user->id; $this->addLog(__('Created panel user: :username (email: :email)', ['username' => $user->username, 'email' => $userEmail]), 'success'); return $user; } catch (Exception $e) { Log::error('Failed to create user from backup', [ 'username' => $this->cpanelUsername, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); $this->addLog(__('Failed to create user: :error', ['error' => $e->getMessage()]), 'error'); return null; } } protected function getCpanel(): ?CpanelApiService { // Try to restore credentials from session if not set (page was reloaded) if (! $this->hostname || ! $this->cpanelUsername || ! $this->apiToken) { $this->restoreCredentialsFromSession(); } if (! $this->hostname || ! $this->cpanelUsername || ! $this->apiToken) { return null; } return $this->cpanel ??= new CpanelApiService( trim($this->hostname), trim($this->cpanelUsername), trim($this->apiToken), $this->port, $this->useSSL ); } /** * Store cPanel credentials in session to survive page reloads. */ protected function storeCredentialsInSession(): void { session()->put('cpanel_migration.hostname', $this->hostname); session()->put('cpanel_migration.username', $this->cpanelUsername); session()->put('cpanel_migration.token', $this->apiToken); session()->put('cpanel_migration.port', $this->port); session()->put('cpanel_migration.useSSL', $this->useSSL); session()->put('cpanel_migration.targetUserId', $this->targetUserId); session()->put('cpanel_migration.isConnected', $this->isConnected); session()->put('cpanel_migration.connectionInfo', $this->connectionInfo); session()->put('cpanel_migration.apiSummary', $this->apiSummary); session()->put('cpanel_migration.sourceType', $this->sourceType); session()->put('cpanel_migration.localBackupPath', $this->localBackupPath); session()->put('cpanel_migration.backupPath', $this->backupPath); session()->put('cpanel_migration.backupFilename', $this->backupFilename); session()->put('cpanel_migration.backupSize', $this->backupSize); session()->put('cpanel_migration.backupInitiated', $this->backupInitiated); session()->put('cpanel_migration.backupMethod', $this->backupMethod); session()->put('cpanel_migration.backupInitiatedAt', $this->backupInitiatedAt); session()->put('cpanel_migration.discoveredData', $this->discoveredData); session()->put('cpanel_migration.step1Complete', $this->step1Complete); // Ensure session is saved before any redirect session()->save(); } /** * Restore cPanel credentials from session after page reload. */ protected function restoreCredentialsFromSession(): void { if (session()->has('cpanel_migration.hostname')) { $this->hostname = session('cpanel_migration.hostname'); $this->cpanelUsername = session('cpanel_migration.username'); $this->apiToken = session('cpanel_migration.token'); $this->port = session('cpanel_migration.port', 2083); $this->useSSL = session('cpanel_migration.useSSL', true); $this->targetUserId = session('cpanel_migration.targetUserId'); $this->isConnected = session('cpanel_migration.isConnected', false); $this->connectionInfo = session('cpanel_migration.connectionInfo', []); $this->apiSummary = session('cpanel_migration.apiSummary', []); $this->sourceType = session('cpanel_migration.sourceType', 'remote'); $this->localBackupPath = session('cpanel_migration.localBackupPath'); $this->backupPath = session('cpanel_migration.backupPath'); $this->backupFilename = session('cpanel_migration.backupFilename'); $this->backupSize = session('cpanel_migration.backupSize', 0); $this->backupInitiated = session('cpanel_migration.backupInitiated', false); $this->backupMethod = session('cpanel_migration.backupMethod', 'download'); $this->backupInitiatedAt = session('cpanel_migration.backupInitiatedAt'); $this->discoveredData = session('cpanel_migration.discoveredData', []); $this->step1Complete = session('cpanel_migration.step1Complete', false); } } /** * Clear stored session credentials. */ protected function clearSessionCredentials(): void { session()->forget([ 'cpanel_migration.hostname', 'cpanel_migration.username', 'cpanel_migration.token', 'cpanel_migration.port', 'cpanel_migration.useSSL', 'cpanel_migration.targetUserId', 'cpanel_migration.isConnected', 'cpanel_migration.connectionInfo', 'cpanel_migration.apiSummary', 'cpanel_migration.sourceType', 'cpanel_migration.localBackupPath', 'cpanel_migration.backupPath', 'cpanel_migration.backupFilename', 'cpanel_migration.backupSize', 'cpanel_migration.backupInitiated', 'cpanel_migration.backupMethod', 'cpanel_migration.backupInitiatedAt', 'cpanel_migration.discoveredData', 'cpanel_migration.step1Complete', ]); } protected function getBackupDestPath(): string { return '/var/backups/jabali/cpanel-migrations'; } protected function getJabaliPublicIp(): string { $ip = trim(shell_exec('curl -s ifconfig.me 2>/dev/null') ?? ''); if (empty($ip)) { $ip = gethostbyname(gethostname()); } return $ip; } protected function getForms(): array { return ['migrationForm']; } public function migrationForm(Schema $schema): Schema { return $schema->schema([ Wizard::make([ $this->getConnectStep(), $this->getBackupStep(), $this->getReviewStep(), $this->getRestoreStep(), ]) ->nextAction( fn (Action $action) => $action ->disabled(fn () => $this->isNextStepDisabled()) ->hidden(fn () => $this->isNextButtonHidden()) ) ->persistStepInQueryString('cpanel-step'), ]); } /** * Check if Next button should be hidden (during backup transfer). */ protected function isNextButtonHidden(): bool { // Hide Next during backup transfer on step 2 (backup started but not complete) // We know we're on step 2 if step1Complete is true return $this->step1Complete && $this->backupInitiated && ! $this->backupPath; } /** * Get normalized current step name from query string. */ protected function getCurrentStepName(): string { // Use Livewire URL-synced property (works in both initial load and Livewire requests) $step = $this->wizardStep ?? 'connect'; // Handle full wizard step IDs like "migrationForm.connect::wizard-step" if (str_contains($step, '.')) { // Extract step name after the dot and before :: if (preg_match('/\.(\w+)(?:::|$)/', $step, $matches)) { return $matches[1]; } } return $step ?: 'connect'; } protected function getConnectStep(): Step { return Step::make(__('Connect')) ->id('connect') ->icon('heroicon-o-link') ->description($this->sourceType === 'local' ? __('Select local backup file') : __('Enter cPanel credentials')) ->schema([ Section::make(__('Target User')) ->description(__('Choose how to handle the user account')) ->icon('heroicon-o-user') ->iconColor('primary') ->schema([ Radio::make('userMode') ->label(__('User Account')) ->options([ 'create' => __('Create new user from backup'), 'existing' => __('Restore to existing user'), ]) ->descriptions([ 'create' => __('Creates a new user with the cPanel username and password from backup (unlimited disk space)'), 'existing' => __('Restore to an existing user account'), ]) ->default('create') ->live() ->required(), Select::make('targetUserId') ->label(__('Select User')) ->options(fn () => User::where('is_active', true) ->orderBy('username') ->pluck('username', 'id') ->mapWithKeys(fn ($username, $id) => [ $id => User::find($id)->name.' ('.$username.')', ]) ) ->searchable() ->required(fn (Get $get) => $get('userMode') === 'existing') ->visible(fn (Get $get) => $get('userMode') === 'existing') ->helperText(__('All migrated domains, emails, and databases will be assigned to this user')), ]), Section::make(__('Backup Source')) ->description(__('Choose where to get the cPanel backup from')) ->icon('heroicon-o-arrow-down-tray') ->iconColor('primary') ->schema([ Select::make('sourceType') ->label(__('Source Type')) ->options([ 'remote' => __('Remote cPanel Server (Create & Transfer Backup)'), 'local' => __('Local File (Already on this server)'), ]) ->default('remote') ->live() ->afterStateUpdated(fn () => $this->resetConnection()) ->helperText(__('Select "Local File" if you already have a cPanel backup on this server')), ]), // Remote cPanel credentials (shown when sourceType is 'remote') Section::make(__('cPanel Credentials')) ->description(__('Enter the cPanel server connection details')) ->icon('heroicon-o-server') ->visible(fn () => $this->sourceType === 'remote') ->schema([ Grid::make(['default' => 1, 'sm' => 2])->schema([ TextInput::make('hostname') ->label(__('cPanel Hostname')) ->placeholder(__('cpanel.example.com')) ->required(fn () => $this->sourceType === 'remote') ->helperText(__('Your cPanel server hostname or IP address')), TextInput::make('port') ->label(__('Port')) ->numeric() ->default(2083) ->required(fn () => $this->sourceType === 'remote') ->helperText(__('Usually 2083 for SSL or 2082 without')), ]), Grid::make(['default' => 1, 'sm' => 2])->schema([ TextInput::make('cpanelUsername') ->label(__('cPanel Username')) ->required(fn () => $this->sourceType === 'remote') ->helperText(__('Your cPanel account username')), TextInput::make('apiToken') ->label(__('API Token')) ->password() ->required(fn () => $this->sourceType === 'remote') ->revealable() ->helperText(__('Generate from cPanel → Security → Manage API Tokens')), ]), Checkbox::make('useSSL') ->label(__('Use SSL (HTTPS)')) ->default(true) ->helperText(__('Recommended. Disable only if your cPanel does not support SSL.')), ]), // Local file selection (shown when sourceType is 'local') Section::make(__('Local Backup File')) ->description(__('Enter the path to the cPanel backup file on this server')) ->icon('heroicon-o-folder') ->visible(fn () => $this->sourceType === 'local') ->schema([ TextInput::make('localBackupPath') ->label(__('Backup File Path')) ->placeholder(__('/home/user/backups/backup-date_username.tar.gz')) ->required(fn () => $this->sourceType === 'local') ->helperText(__('Full path to the cPanel backup file (e.g., /var/backups/backup.tar.gz)')), Text::make(__('Supported formats: .tar.gz, .tgz'))->color('gray'), Text::make(__('Tip: Upload backups to /var/backups/jabali/cpanel-migrations/'))->color('gray'), ]), // Test Connection button (remote mode only) FormActions::make([ Action::make('testConnection') ->label(fn () => $this->isConnected ? __('Connected') : __('Test Connection')) ->icon(fn () => $this->isConnected ? 'heroicon-o-check-circle' : 'heroicon-o-signal') ->color(fn () => $this->isConnected ? 'success' : 'primary') ->disabled(fn () => $this->userMode === 'existing' && ! $this->targetUserId) ->tooltip(fn () => $this->userMode === 'existing' && ! $this->targetUserId ? __('Please select a user first') : null) ->action('testConnection'), ]) ->alignEnd() ->visible(fn () => $this->sourceType === 'remote'), // Validate local file button (local mode only) FormActions::make([ Action::make('validateLocalFile') ->label(fn () => $this->isConnected ? __('File Validated') : __('Validate Backup File')) ->icon(fn () => $this->isConnected ? 'heroicon-o-check-circle' : 'heroicon-o-document-magnifying-glass') ->color(fn () => $this->isConnected ? 'success' : 'primary') ->disabled(fn () => $this->userMode === 'existing' && ! $this->targetUserId) ->tooltip(fn () => $this->userMode === 'existing' && ! $this->targetUserId ? __('Please select a user first') : null) ->action('validateLocalFile'), ]) ->alignEnd() ->visible(fn () => $this->sourceType === 'local'), Section::make(__('Connection Successful')) ->icon('heroicon-o-check-circle') ->iconColor('success') ->visible(fn () => $this->isConnected && $this->sourceType === 'remote') ->schema([ Grid::make(['default' => 2, 'sm' => 5])->schema([ Text::make(fn () => __('Host: :host', ['host' => $this->hostname ?? '-'])), Text::make(fn () => __('Domains: :count', ['count' => $this->connectionInfo['domains'] ?? 0])), Text::make(fn () => __('Databases: :count', ['count' => $this->connectionInfo['databases'] ?? 0])), Text::make(fn () => __('Emails: :count', ['count' => $this->connectionInfo['emails'] ?? 0])), Text::make(fn () => __('SSL: :count', ['count' => $this->connectionInfo['ssl'] ?? 0])), ]), Text::make(__('You can proceed to the next step.'))->color('success'), ]), Section::make(__('File Validated')) ->icon('heroicon-o-check-circle') ->iconColor('success') ->visible(fn () => $this->isConnected && $this->sourceType === 'local') ->schema([ Text::make(fn () => __('File: :path', ['path' => basename($this->localBackupPath ?? '')])), Text::make(fn () => __('Size: :size', ['size' => $this->formatBytes(filesize($this->localBackupPath ?? '') ?: 0)])), Text::make(__('You can proceed to the next step.'))->color('success'), ]), ]) ->afterValidation(function () { // Only require target user if 'existing' mode is selected if ($this->userMode === 'existing' && ! $this->targetUserId) { Notification::make() ->title(__('User required')) ->body(__('Please select a target user')) ->danger() ->send(); throw new Exception(__('Please select a target user first')); } if (! $this->isConnected) { $message = $this->sourceType === 'local' ? __('Please validate the backup file before proceeding') : __('Please test the connection first'); Notification::make() ->title($this->sourceType === 'local' ? __('Validation required') : __('Connection required')) ->body($message) ->danger() ->send(); throw new Exception($message); } // Mark step 1 as complete - user is moving to step 2 $this->step1Complete = true; }); } protected function getBackupStep(): Step { // For local files - analyze backup if ($this->sourceType === 'local') { return Step::make(__('Backup')) ->id('backup') ->icon('heroicon-o-folder-open') ->description(__('Analyzing local backup')) ->schema([ Section::make(__('Local Backup')) ->description(__('Click the button below to analyze the backup contents')) ->icon('heroicon-o-folder-open') ->iconColor('primary') ->headerActions([ Action::make('analyzeBackup') ->label(__('Analyze Backup')) ->icon('heroicon-o-magnifying-glass') ->color('primary') ->disabled(fn () => $this->isAnalyzing || ! empty($this->discoveredData)) ->action('analyzeLocalBackup'), ]) ->schema([ Text::make(__('Target User: :name (:username)', [ 'name' => $this->getTargetUser()?->name ?? '-', 'username' => $this->getTargetUser()?->username ?? '-', ])), Text::make(__('File: :name', ['name' => $this->backupFilename ?? basename($this->localBackupPath ?? '')])), Text::make(__('Size: :size', ['size' => $this->formatBytes($this->backupSize)])), ]), Section::make(__('Analysis Progress')) ->icon($this->getAnalysisStatusIcon()) ->iconColor($this->getAnalysisStatusColor()) ->schema($this->getLocalBackupStatusSchema()) ->extraAttributes($this->isAnalyzing ? ['wire:poll.1s' => 'pollAnalysisStatus'] : []), ]) ->afterValidation(function () { if (empty($this->discoveredData)) { Notification::make() ->title(__('Analysis required')) ->body(__('Please analyze the backup file before proceeding')) ->danger() ->send(); $this->halt(); } }); } // Remote cPanel - create and transfer backup return Step::make(__('Backup')) ->id('backup') ->icon('heroicon-o-cloud-arrow-down') ->description(__('Create and transfer backup')) ->schema([ Section::make(__('Backup Transfer')) ->description(__('Click the button below to create and transfer the backup from cPanel')) ->icon('heroicon-o-server') ->headerActions([ Action::make('startBackup') ->label(__('Start Backup Transfer')) ->icon('heroicon-o-cloud-arrow-down') ->color('primary') ->disabled(fn () => $this->backupInitiated || (bool) $this->backupPath) ->action('startBackupTransfer'), ]) ->schema([ Text::make(__('Target User: :name (:username)', [ 'name' => $this->getTargetUser()?->name ?? '-', 'username' => $this->getTargetUser()?->username ?? '-', ])), Text::make(__('Note: Large accounts may take several minutes.'))->color('warning'), ]), Section::make(__('Transfer Status')) ->icon($this->backupPath ? 'heroicon-o-check-circle' : 'heroicon-o-clock') ->iconColor($this->backupPath ? 'success' : 'gray') ->schema($this->getStatusLogSchema()) ->extraAttributes($this->backupInitiated && ! $this->backupPath ? ['wire:poll.5s' => 'checkBackupStatus'] : []), ]) ->afterValidation(function () { if (! $this->backupPath) { Notification::make() ->title(__('Backup required')) ->body(__('Please complete the backup transfer first')) ->danger() ->send(); $this->halt(); } }); } protected function getStatusLogSchema(): array { if (empty($this->statusLog)) { return [ Text::make(__('Click "Start Backup Transfer" to begin.'))->color('gray'), ]; } $items = []; foreach ($this->statusLog as $entry) { $color = match ($entry['status']) { 'success' => 'success', 'error' => 'danger', 'pending' => 'warning', default => 'gray', }; $prefix = match ($entry['status']) { 'success' => '✓ ', 'error' => '✗ ', 'pending' => '○ ', default => '• ', }; $items[] = Text::make($prefix.$entry['message']) ->color($color); } if ($this->backupPath) { $items[] = Section::make(__('Backup Complete')) ->icon('heroicon-o-check-circle') ->iconColor('success') ->schema([ Text::make(__('File: :name', ['name' => $this->backupFilename ?? basename($this->backupPath)])), Text::make(__('Size: :size', ['size' => $this->formatBytes($this->backupSize)])), ]) ->compact(); } return $items; } protected function getLocalBackupStatusSchema(): array { $items = []; // Show analysis log entries if (! empty($this->analysisLog)) { foreach ($this->analysisLog as $entry) { $prefix = match ($entry['status']) { 'success' => '✓ ', 'error' => '✗ ', 'pending' => '○ ', default => '• ', }; $color = match ($entry['status']) { 'success' => 'success', 'error' => 'danger', 'pending' => 'warning', default => 'gray', }; $items[] = Text::make($prefix.$entry['message'])->color($color); } } // Show initial message if no log and not analyzing if (empty($this->analysisLog) && ! $this->isAnalyzing && empty($this->discoveredData)) { return [ Text::make(__('Click "Analyze Backup" to discover the backup contents.'))->color('gray'), ]; } // Show results if analysis complete if (! empty($this->discoveredData)) { $domainCount = count($this->discoveredData['domains'] ?? []); $dbCount = count($this->discoveredData['databases'] ?? []); $mailCount = count($this->discoveredData['mailboxes'] ?? []); $forwarderCount = count($this->discoveredData['forwarders'] ?? []); $sslCount = count($this->discoveredData['ssl_certificates'] ?? []); $items[] = Grid::make(['default' => 2, 'sm' => 5])->schema([ Section::make((string) $domainCount) ->description(__('Domains')) ->icon('heroicon-o-globe-alt') ->iconColor('primary') ->compact(), Section::make((string) $dbCount) ->description(__('Databases')) ->icon('heroicon-o-circle-stack') ->iconColor('warning') ->compact(), Section::make((string) $mailCount) ->description(__('Mailboxes')) ->icon('heroicon-o-envelope') ->iconColor('success') ->compact(), Section::make((string) $forwarderCount) ->description(__('Forwarders')) ->icon('heroicon-o-arrow-uturn-right') ->iconColor('gray') ->compact(), Section::make((string) $sslCount) ->description(__('SSL Certs')) ->icon('heroicon-o-lock-closed') ->iconColor('info') ->compact(), ]); $items[] = Text::make(__('You can proceed to the next step.'))->color('success'); } return $items; } protected function getAnalyzeButtonLabel(): string { if ($this->isAnalyzing) { return __('Analyzing...'); } if (! empty($this->discoveredData)) { return __('Analysis Complete'); } return __('Analyze Backup'); } protected function getAnalyzeButtonIcon(): string { if ($this->isAnalyzing) { return 'heroicon-o-arrow-path'; } if (! empty($this->discoveredData)) { return 'heroicon-o-check-circle'; } return 'heroicon-o-magnifying-glass'; } protected function getAnalyzeButtonColor(): string { if ($this->isAnalyzing) { return 'warning'; } if (! empty($this->discoveredData)) { return 'success'; } return 'primary'; } protected function getAnalysisStatusIcon(): string { if (! empty($this->discoveredData)) { return 'heroicon-o-check-circle'; } if ($this->isAnalyzing) { return 'heroicon-o-arrow-path'; } return 'heroicon-o-clock'; } protected function getAnalysisStatusColor(): string { if (! empty($this->discoveredData)) { return 'success'; } if ($this->isAnalyzing) { return 'warning'; } return 'gray'; } protected function addAnalysisLog(string $message, string $status = 'info'): void { // Update the last pending entry if this is a completion $lastIndex = count($this->analysisLog) - 1; if ($lastIndex >= 0 && $this->analysisLog[$lastIndex]['status'] === 'pending' && $status !== 'pending') { $this->analysisLog[$lastIndex] = [ 'message' => $message, 'status' => $status, 'time' => now()->format('H:i:s'), ]; return; } $this->analysisLog[] = [ 'message' => $message, 'status' => $status, 'time' => now()->format('H:i:s'), ]; } public function pollAnalysisStatus(): void { // This method is called by wire:poll to refresh the UI during analysis // The actual work is done in analyzeLocalBackup } protected function getReviewStep(): Step { return Step::make(__('Review')) ->id('review') ->icon('heroicon-o-clipboard-document-check') ->description(__('Review discovered data')) ->schema($this->getReviewStepSchema()); } protected function getReviewStepSchema(): array { if (empty($this->discoveredData)) { return [ Section::make(__('Waiting for Backup')) ->icon('heroicon-o-clock') ->iconColor('warning') ->schema([ Text::make(__('Please complete the backup transfer in the previous step.')), ]), ]; } $user = $this->getTargetUser(); $domainCount = count($this->discoveredData['domains'] ?? []); $dbCount = count($this->discoveredData['databases'] ?? []); $mailCount = count($this->discoveredData['mailboxes'] ?? []); $forwarderCount = count($this->discoveredData['forwarders'] ?? []); $sslCount = count($this->discoveredData['ssl_certificates'] ?? []); return [ Section::make(__('Migration Summary')) ->icon('heroicon-o-clipboard-document-list') ->iconColor('primary') ->schema([ Text::make(__('Target User: :name (:username)', [ 'name' => $user?->name ?? '-', 'username' => $user?->username ?? '-', ])), Grid::make(['default' => 2, 'sm' => 5])->schema([ Section::make((string) $domainCount) ->description(__('Domains')) ->icon('heroicon-o-globe-alt') ->iconColor('primary') ->compact(), Section::make((string) $dbCount) ->description(__('Databases')) ->icon('heroicon-o-circle-stack') ->iconColor('warning') ->compact(), Section::make((string) $mailCount) ->description(__('Mailboxes')) ->icon('heroicon-o-envelope') ->iconColor('success') ->compact(), Section::make((string) $forwarderCount) ->description(__('Forwarders')) ->icon('heroicon-o-arrow-uturn-right') ->iconColor('gray') ->compact(), Section::make((string) $sslCount) ->description(__('SSL Certs')) ->icon('heroicon-o-lock-closed') ->iconColor('info') ->compact(), ]), ]), Section::make(__('What to Restore')) ->description(__('Select which parts of the backup to restore')) ->icon('heroicon-o-check-circle') ->schema([ Grid::make(['default' => 1, 'sm' => 2])->schema([ Checkbox::make('restoreFiles') ->label(__('Website Files')) ->helperText(__('Restore all website files to the user\'s domains folder')) ->default(true), Checkbox::make('restoreDatabases') ->label(__('MySQL Databases')) ->helperText(__('Restore databases with all data')) ->default(true), Checkbox::make('restoreEmails') ->label(__('Email Mailboxes')) ->helperText(__('Restore email accounts and messages')) ->default(true), Checkbox::make('restoreSsl') ->label(__('SSL Certificates')) ->helperText(__('Restore SSL certificates for domains')) ->default(true), ]), ]), Section::make(__('Discovered Data')) ->icon('heroicon-o-magnifying-glass') ->schema([ Tabs::make('DataTabs') ->tabs([ Tabs\Tab::make(__('Domains')) ->icon('heroicon-o-globe-alt') ->badge((string) $domainCount) ->schema($this->getDomainsTabContent()), Tabs\Tab::make(__('Databases')) ->icon('heroicon-o-circle-stack') ->badge((string) $dbCount) ->schema($this->getDatabasesTabContent()), Tabs\Tab::make(__('Mailboxes')) ->icon('heroicon-o-envelope') ->badge((string) $mailCount) ->schema($this->getMailboxesTabContent()), Tabs\Tab::make(__('Forwarders')) ->icon('heroicon-o-arrow-uturn-right') ->badge((string) $forwarderCount) ->schema($this->getForwardersTabContent()), Tabs\Tab::make(__('SSL')) ->icon('heroicon-o-lock-closed') ->badge((string) $sslCount) ->schema($this->getSslTabContent()), ]), ]), ]; } protected function getDomainsTabContent(): array { $domains = $this->discoveredData['domains'] ?? []; if (empty($domains)) { return [Text::make(__('No domains found in backup.'))]; } $items = []; foreach ($domains as $domain) { $typePrefix = match ($domain['type'] ?? 'addon') { 'main' => '★ ', 'addon' => '● ', 'sub' => '◦ ', default => '• ', }; $typeColor = match ($domain['type'] ?? 'addon') { 'main' => 'success', 'addon' => 'primary', 'sub' => 'warning', default => 'gray', }; $items[] = Text::make($typePrefix.$domain['name'].' ('.$domain['type'].')') ->color($typeColor); } return $items; } protected function getDatabasesTabContent(): array { $databases = $this->discoveredData['databases'] ?? []; if (empty($databases)) { return [Text::make(__('No databases found in backup.'))]; } $user = $this->getTargetUser(); $userPrefix = $user?->username ?? 'user'; $items = []; foreach ($databases as $db) { $oldName = $db['name']; $newName = $userPrefix.'_'.preg_replace('/^[^_]+_/', '', $oldName); $newName = substr($newName, 0, 64); $items[] = Text::make("→ {$oldName} → {$newName}") ->color('primary'); } return $items; } protected function getMailboxesTabContent(): array { $mailboxes = $this->discoveredData['mailboxes'] ?? []; if (empty($mailboxes)) { return [Text::make(__('No mailboxes found in backup.'))]; } $items = []; foreach ($mailboxes as $mailbox) { $items[] = Text::make('✉ '.$mailbox['email']) ->color('success'); } return $items; } protected function getSslTabContent(): array { $sslCerts = $this->discoveredData['ssl_certificates'] ?? []; if (empty($sslCerts)) { return [Text::make(__('No SSL certificates found in backup.'))]; } $items = []; foreach ($sslCerts as $cert) { $hasComplete = ($cert['has_key'] ?? false) && ($cert['has_cert'] ?? false); $prefix = $hasComplete ? '🔒 ' : '⚠ '; $items[] = Text::make($prefix.$cert['domain']) ->color($hasComplete ? 'success' : 'warning'); } return $items; } protected function getForwardersTabContent(): array { $forwarders = $this->discoveredData['forwarders'] ?? []; if (empty($forwarders)) { return [Text::make(__('No forwarders found in backup.'))]; } $items = []; foreach ($forwarders as $forwarder) { $email = $forwarder['email'] ?? ''; $destinations = $forwarder['destinations'] ?? ''; $destPreview = is_array($destinations) ? implode(', ', $destinations) : $destinations; $destPreview = strlen($destPreview) > 40 ? substr($destPreview, 0, 37).'...' : $destPreview; $items[] = Text::make('↪ '.$email.' → '.$destPreview) ->color('gray'); } return $items; } protected function getRestoreStep(): Step { return Step::make(__('Restore')) ->id('restore') ->icon('heroicon-o-play') ->description(__('Migration progress')) ->schema([ FormActions::make([ Action::make('startRestore') ->label(__('Start Restore')) ->icon('heroicon-o-play') ->color('success') ->visible(fn () => ! $this->isProcessing && empty($this->migrationLog)) ->requiresConfirmation() ->modalHeading(__('Start Restore')) ->modalDescription(__('This will restore the selected items. Existing data may be overwritten.')) ->action('startRestore'), Action::make('newMigration') ->label(__('New Migration')) ->icon('heroicon-o-plus') ->color('primary') ->visible(fn () => ! $this->isProcessing && ! empty($this->migrationLog)) ->action('resetMigration'), ])->alignEnd(), Section::make(__('Migration Progress')) ->icon($this->isProcessing ? 'heroicon-o-arrow-path' : ($this->migrationLog ? 'heroicon-o-check-circle' : 'heroicon-o-clock')) ->iconColor($this->isProcessing ? 'warning' : ($this->migrationLog ? 'success' : 'gray')) ->schema($this->getMigrationLogSchema()) ->extraAttributes($this->isProcessing ? ['wire:poll.1s' => 'pollMigrationLog'] : []), ]); } protected function getMigrationLogSchema(): array { if (empty($this->migrationLog)) { return [ Text::make(__('Click "Start Restore" to begin the migration.'))->color('gray'), ]; } $items = []; foreach ($this->migrationLog as $entry) { $status = $entry['status'] ?? 'info'; $message = $entry['message'] ?? ''; $color = match ($status) { 'success' => 'success', 'error' => 'danger', 'warning' => 'warning', 'pending' => 'warning', default => 'gray', }; $prefix = match ($status) { 'success' => '✓ ', 'error' => '✗ ', 'warning' => '• ', 'pending' => '○ ', default => '• ', }; $items[] = Text::make($prefix.$message)->color($color); } return $items; } public function table(Table $table): Table { // Empty table - data is displayed via schema components return $table ->query(User::query()->whereRaw('1 = 0')) ->columns([]) ->paginated(false); } public function testConnection(): void { if ($this->userMode === 'existing' && ! $this->targetUserId) { Notification::make() ->title(__('User required')) ->body(__('Please select a target user first')) ->danger() ->send(); return; } if (empty($this->hostname) || empty($this->cpanelUsername) || empty($this->apiToken)) { Notification::make() ->title(__('Missing credentials')) ->body(__('Please fill in all required fields')) ->danger() ->send(); return; } try { $cpanel = $this->getCpanel(); $result = $cpanel->testConnection(); if ($result['success']) { $this->isConnected = true; $summary = $cpanel->getMigrationSummary(); // Store full API summary for use during restore (skips expensive backup analysis) $this->apiSummary = $summary; // Immediately populate discoveredData from API - no need to wait for backup analysis $this->discoveredData = $this->convertApiDataToAgentFormat($summary); $this->connectionInfo = [ 'domains' => count($this->discoveredData['domains'] ?? []), 'emails' => count($this->discoveredData['mailboxes'] ?? []), 'databases' => count($this->discoveredData['databases'] ?? []), 'ssl' => count($this->discoveredData['ssl_certificates'] ?? []), ]; Notification::make() ->title(__('Connection successful')) ->body(__('Found :domains domains, :emails email accounts, :dbs databases, :ssl SSL certificates', [ 'domains' => $this->connectionInfo['domains'], 'emails' => $this->connectionInfo['emails'], 'dbs' => $this->connectionInfo['databases'], 'ssl' => $this->connectionInfo['ssl'], ])) ->success() ->send(); } else { throw new Exception($result['message'] ?? __('Connection failed')); } } catch (Exception $e) { $this->isConnected = false; Log::error('cPanel connection failed', ['error' => $e->getMessage()]); Notification::make() ->title(__('Connection failed')) ->body($e->getMessage()) ->danger() ->send(); } } /** * Check if the Next button should be disabled based on current wizard step. * Uses step1Complete flag to reliably track wizard progress. */ protected function isNextStepDisabled(): bool { // If step 1 is not complete, we're on step 1 if (! $this->step1Complete) { // Step 1: Must have connection (and target user if 'existing' mode) return ! $this->isConnected || ($this->userMode === 'existing' && ! $this->targetUserId); } // Step 1 is complete, we're on step 2 or beyond // Check step 2 prerequisites (backup ready) if ($this->sourceType === 'local') { // Local: need analysis complete if (empty($this->discoveredData)) { return true; } } else { // Remote: need backup downloaded if (! $this->backupPath) { return true; } } // All prerequisites met (step 3 and beyond) return false; } protected function countDomains(array $domains): int { $count = 0; if (! empty($domains['main'])) { $count++; } $count += count($domains['addon'] ?? []); $count += count($domains['sub'] ?? []); return $count; } /** * Convert API migration summary to backup analysis format for the agent. * API format: ['domains' => ['main' => 'x', 'addon' => [...]], 'databases' => [...], 'email_accounts' => [...], 'ssl_certificates' => [...]] * Agent format: ['domains' => [['name' => 'x', 'type' => 'main']], 'databases' => [...], 'mailboxes' => [...], 'ssl_certificates' => [...]] */ protected function convertApiDataToAgentFormat(array $apiData): array { $result = [ 'domains' => [], 'databases' => [], 'mailboxes' => [], 'ssl_certificates' => [], ]; // Convert domains $domains = $apiData['domains'] ?? []; if (! empty($domains['main'])) { $result['domains'][] = ['name' => $domains['main'], 'type' => 'main']; } foreach ($domains['addon'] ?? [] as $domain) { $result['domains'][] = ['name' => $domain, 'type' => 'addon']; } foreach ($domains['sub'] ?? [] as $domain) { $result['domains'][] = ['name' => $domain, 'type' => 'sub']; } foreach ($domains['parked'] ?? [] as $domain) { $result['domains'][] = ['name' => $domain, 'type' => 'parked']; } // Convert databases (API returns array of database names or objects) foreach ($apiData['databases'] ?? [] as $db) { $dbName = is_array($db) ? ($db['database'] ?? $db['name'] ?? '') : $db; if ($dbName) { $result['databases'][] = ['name' => $dbName, 'file' => "mysql/{$dbName}.sql"]; } } // Convert email accounts to mailboxes format foreach ($apiData['email_accounts'] ?? [] as $email) { $emailAddr = is_array($email) ? ($email['email'] ?? '') : $email; if ($emailAddr && str_contains($emailAddr, '@')) { [$localPart, $domain] = explode('@', $emailAddr, 2); $result['mailboxes'][] = [ 'email' => $emailAddr, 'local_part' => $localPart, 'domain' => $domain, ]; } } // Convert SSL certificates - handle various cPanel API response formats foreach ($apiData['ssl_certificates'] ?? [] as $cert) { if (is_array($cert)) { // cPanel API may return 'domain', 'domains' (array), or 'friendly_name' $domain = $cert['domain'] ?? $cert['friendly_name'] ?? null; if (! $domain && ! empty($cert['domains'])) { // 'domains' can be array or comma-separated string $domains = is_array($cert['domains']) ? $cert['domains'] : explode(',', $cert['domains']); $domain = trim($domains[0] ?? ''); } if ($domain) { $result['ssl_certificates'][] = [ 'domain' => $domain, 'has_key' => true, // API only lists valid certs 'has_cert' => true, ]; } } elseif (is_string($cert) && ! empty($cert)) { $result['ssl_certificates'][] = [ 'domain' => $cert, 'has_key' => true, 'has_cert' => true, ]; } } return $result; } /** * Quick scan backup file for SSL certificates without full extraction. * Updates discoveredData['ssl_certificates'] with found certs. */ protected function scanBackupForSsl(string $backupPath): int { if (! file_exists($backupPath)) { return 0; } // Quick scan of tar.gz contents for SSL files $output = []; exec('tar -tzf '.escapeshellarg($backupPath).' 2>/dev/null | grep -E "ssl/(certs|keys)/.*\.(crt|key|pem)$" | head -100', $output); $sslSet = []; foreach ($output as $file) { // Match cPanel SSL cert format: domain_keyid_timestamp_hash.crt if (preg_match('/ssl\/certs\/(.+)_([a-f0-9]+_[a-f0-9]+)_\d+_[a-f0-9]+\.(crt|pem)$/i', $file, $matches)) { $domain = str_replace('_', '.', $matches[1]); $keyId = $matches[2]; if (! isset($sslSet[$keyId])) { $sslSet[$keyId] = ['domain' => $domain, 'has_cert' => true, 'has_key' => false]; } else { $sslSet[$keyId]['has_cert'] = true; $sslSet[$keyId]['domain'] = $domain; } } // Match key files: keyid_hash.key elseif (preg_match('/ssl\/keys\/([a-f0-9]+_[a-f0-9]+)_[a-f0-9]+\.key$/i', $file, $matches)) { $keyId = $matches[1]; if (! isset($sslSet[$keyId])) { $sslSet[$keyId] = ['domain' => '', 'has_cert' => false, 'has_key' => true]; } else { $sslSet[$keyId]['has_key'] = true; } } } // Build SSL certificates list from matched cert+key pairs $sslCerts = []; foreach ($sslSet as $keyId => $info) { if ($info['has_cert'] && $info['has_key'] && ! empty($info['domain'])) { $sslCerts[] = [ 'domain' => $info['domain'], 'has_key' => true, 'has_cert' => true, ]; } } // Update discoveredData $this->discoveredData['ssl_certificates'] = $sslCerts; return count($sslCerts); } public function validateLocalFile(): void { if (empty($this->localBackupPath)) { Notification::make() ->title(__('Missing path')) ->body(__('Please enter the backup file path')) ->danger() ->send(); return; } $path = trim($this->localBackupPath); // Validate file exists if (! file_exists($path)) { Notification::make() ->title(__('File not found')) ->body(__('The specified file does not exist: :path', ['path' => $path])) ->danger() ->send(); return; } // Validate it's a file (not directory) if (! is_file($path)) { Notification::make() ->title(__('Invalid path')) ->body(__('The specified path is not a file')) ->danger() ->send(); return; } // Validate extension if (! preg_match('/\.(tar\.gz|tgz)$/i', $path)) { Notification::make() ->title(__('Invalid format')) ->body(__('Backup must be a .tar.gz or .tgz file')) ->danger() ->send(); return; } // Validate it's readable if (! is_readable($path)) { Notification::make() ->title(__('File not readable')) ->body(__('Cannot read the backup file. Check permissions.')) ->danger() ->send(); return; } // Quick validation - try to list contents $output = []; exec('tar -I pigz -tf '.escapeshellarg($path).' 2>&1 | head -5', $output, $returnCode); if ($returnCode !== 0) { Notification::make() ->title(__('Invalid backup')) ->body(__('The file does not appear to be a valid cPanel backup archive')) ->danger() ->send(); return; } // File is valid $this->localBackupPath = $path; $this->isConnected = true; // Set backup path immediately for local files $this->backupPath = $path; $this->backupFilename = basename($path); $this->backupSize = filesize($path) ?: 0; Notification::make() ->title(__('File validated')) ->body(__('Backup file is valid. Size: :size', ['size' => $this->formatBytes($this->backupSize)])) ->success() ->send(); } public function startBackupTransfer(): void { if ($this->backupPath) { return; } if ($this->backupInitiated) { $this->checkBackupStatus(); return; } $this->statusLog = []; $this->addStatusLog(__('Starting backup transfer process...'), 'pending'); $cpanel = $this->getCpanel(); if (! $cpanel) { $this->addStatusLog(__('Error: cPanel credentials not available. Please go back and reconnect.'), 'error'); Notification::make() ->title(__('Connection lost')) ->body(__('Please go back to the Connect step and test the connection again.')) ->danger() ->send(); return; } $destPath = $this->getBackupDestPath(); if (! is_dir($destPath)) { mkdir($destPath, 0755, true); } // Method 1: Try to create backup to homedir and download via HTTP (more reliable) try { $this->addStatusLog(__('Initiating backup on cPanel (homedir method)...'), 'pending'); $backupResult = $cpanel->createBackup(); if (! ($backupResult['success'] ?? false)) { throw new Exception($backupResult['message'] ?? __('Failed to start backup')); } $this->backupInitiated = true; $this->backupPid = $backupResult['pid'] ?? null; $this->backupMethod = 'download'; // Track which method we're using $this->backupInitiatedAt = time(); // Track when backup started $this->lastSeenBackupSize = null; // Reset size tracking $this->pollCount = 0; $this->addStatusLog(__('Backup initiated on cPanel'), 'success'); $this->addStatusLog(__('Waiting for backup to complete on cPanel...'), 'pending'); Notification::make() ->title(__('Backup started')) ->body(__('cPanel is creating the backup. Once complete, it will be downloaded.')) ->info() ->send(); return; } catch (Exception $e) { Log::warning('Homedir backup failed, trying SCP method', ['error' => $e->getMessage()]); $this->addStatusLog(__('Homedir backup failed, trying SCP transfer...'), 'warning'); } // Method 2: Fall back to SCP transfer (requires SSH access on cPanel) $this->startScpBackupTransfer(); } protected function startScpBackupTransfer(): void { $cpanel = $this->getCpanel(); if (! $cpanel) { $this->addStatusLog(__('Error: cPanel credentials not available. Please go back and reconnect.'), 'error'); return; } $destPath = $this->getBackupDestPath(); if (! is_dir($destPath)) { mkdir($destPath, 0755, true); } try { $this->addStatusLog(__('Checking Jabali SSH key...'), 'pending'); $sshKeyResult = $this->getAgent()->send('jabali_ssh.ensure_exists', []); if (! ($sshKeyResult['success'] ?? false)) { throw new Exception($sshKeyResult['error'] ?? __('Failed to generate Jabali SSH key')); } $publicKey = $sshKeyResult['public_key'] ?? null; $keyName = $sshKeyResult['key_name'] ?? 'jabali-system-key'; if (! $publicKey) { throw new Exception(__('Failed to read Jabali public key')); } $this->addStatusLog(__('Jabali SSH key ready'), 'success'); $this->addStatusLog(__('Configuring SSH access on Jabali...'), 'pending'); $privateKeyResult = $this->getAgent()->send('jabali_ssh.get_private_key', []); if (! ($privateKeyResult['success'] ?? false) || ! ($privateKeyResult['exists'] ?? false)) { throw new Exception(__('Failed to read Jabali private key')); } $privateKey = $privateKeyResult['private_key'] ?? null; if (! $privateKey) { throw new Exception(__('Private key is empty')); } $authKeysResult = $this->getAgent()->send('jabali_ssh.add_to_authorized_keys', [ 'public_key' => $publicKey, 'comment' => 'cpanel-migration-'.$this->cpanelUsername, ]); if (! ($authKeysResult['success'] ?? false)) { throw new Exception($authKeysResult['error'] ?? __('Failed to add key to authorized_keys')); } $this->addStatusLog(__('SSH access configured on Jabali'), 'success'); $this->addStatusLog(__('Preparing SSH key on cPanel...'), 'pending'); $cpanel->deleteSshKey($keyName, 'key'); $cpanel->deleteSshKey($keyName, 'key.pub'); $this->addStatusLog(__('Importing SSH key to cPanel...'), 'pending'); $importResult = $cpanel->importSshPrivateKey($keyName, $privateKey); if (! ($importResult['success'] ?? false)) { throw new Exception($importResult['message'] ?? __('Failed to import SSH key')); } $this->addStatusLog(__('SSH key imported to cPanel'), 'success'); $this->addStatusLog(__('Authorizing SSH key...'), 'pending'); $authResult = $cpanel->authorizeSshKey($keyName); if (! ($authResult['success'] ?? false)) { $this->addStatusLog(__('SSH key authorization skipped'), 'info'); } else { $this->addStatusLog(__('SSH key authorized'), 'success'); } $this->addStatusLog(__('Initiating backup on cPanel (SCP method)...'), 'pending'); $jabaliIp = $this->getJabaliPublicIp(); $backupResult = $cpanel->createBackupToScpWithKey( $jabaliIp, 'root', $destPath, $keyName, 22 ); if (! ($backupResult['success'] ?? false)) { throw new Exception($backupResult['message'] ?? __('Failed to start backup')); } $this->backupInitiated = true; $this->backupPid = $backupResult['pid'] ?? null; $this->backupMethod = 'scp'; $this->backupInitiatedAt = time(); $this->lastSeenBackupSize = null; $this->pollCount = 0; $this->addStatusLog(__('Backup initiated on cPanel'), 'success'); $this->addStatusLog(__('Waiting for backup file to arrive...'), 'pending'); Notification::make() ->title(__('Backup transfer started')) ->body(__('cPanel is creating and transferring the backup. This may take several minutes.')) ->info() ->send(); } catch (Exception $e) { Log::error('Backup transfer failed', ['error' => $e->getMessage()]); $this->addStatusLog(__('Error: :message', ['message' => $e->getMessage()]), 'error'); Notification::make() ->title(__('Backup transfer failed')) ->body($e->getMessage()) ->danger() ->send(); } } public function checkBackupStatus(): void { $this->pollCount++; $destPath = $this->getBackupDestPath(); // For download method, we need to check cPanel for backup completion then download if ($this->backupMethod === 'download') { $this->checkAndDownloadBackup($destPath); return; } // For SCP method, check for file arrival $files = glob($destPath.'/backup-*.tar.gz'); // Filter to only files created after backup was initiated if ($this->backupInitiatedAt) { $files = array_filter($files, function ($file) { return filemtime($file) >= ($this->backupInitiatedAt - 60); }); $files = array_values($files); } if (! empty($files)) { usort($files, fn ($a, $b) => filemtime($b) - filemtime($a)); $backupFile = $files[0]; $size1 = filesize($backupFile); usleep(500000); clearstatcache(true, $backupFile); $size2 = filesize($backupFile); if ($size1 !== $size2) { $this->addStatusLog(__('Receiving backup file... (:size)', ['size' => $this->formatBytes($size2)]), 'pending'); return; } // Fix file permissions (SCP creates files as root, need agent to fix) $this->getAgent()->send('cpanel.fix_backup_permissions', [ 'backup_path' => $backupFile, ]); // Verify the file is a valid gzip archive $handle = fopen($backupFile, 'rb'); $magic = $handle ? fread($handle, 2) : ''; if ($handle) { fclose($handle); } if ($magic !== "\x1f\x8b") { $this->addStatusLog(__('Received invalid backup file, waiting...'), 'pending'); @unlink($backupFile); return; } $this->backupPath = $backupFile; $this->backupFilename = basename($backupFile); $this->backupSize = filesize($backupFile); $this->addStatusLog(__('Backup file received'), 'success'); $this->cleanupCpanelSshKey(); // Always analyze backup to get accurate data (API may not have all permissions) $this->analyzeBackup(); Notification::make() ->title(__('Backup received')) ->body(__('Backup file :name (:size) is ready. Click Next to continue.', [ 'name' => $this->backupFilename, 'size' => $this->formatBytes($this->backupSize), ])) ->success() ->send(); } else { $this->addStatusLog(__('Waiting for backup file... (check :count)', ['count' => $this->pollCount]), 'pending'); } } protected function checkAndDownloadBackup(string $destPath): void { try { $cpanel = $this->getCpanel(); // Check backup status on cPanel $statusResult = $cpanel->getBackupStatus(); if (! ($statusResult['success'] ?? false)) { $this->addStatusLog(__('Checking backup status... (attempt :count)', ['count' => $this->pollCount]), 'pending'); return; } // Check if backup is still in progress if ($statusResult['in_progress'] ?? false) { $this->addStatusLog(__('Backup in progress on cPanel... (check :count)', ['count' => $this->pollCount]), 'pending'); return; } // Look for completed backup files $backups = $statusResult['backups'] ?? []; if (empty($backups)) { // Also try listBackups for older cPanel versions $listResult = $cpanel->listBackups(); $backups = $listResult['backups'] ?? []; } // Filter to only backups created AFTER we initiated the backup // This prevents picking up old backup files if ($this->backupInitiatedAt) { $backups = array_filter($backups, function ($backup) { $mtime = $backup['mtime'] ?? 0; // Allow 60 second buffer before initiation time return $mtime >= ($this->backupInitiatedAt - 60); }); $backups = array_values($backups); // Re-index array } if (empty($backups)) { $this->addStatusLog(__('Waiting for backup to complete... (check :count)', ['count' => $this->pollCount]), 'pending'); return; } // Get the most recent backup $latestBackup = $backups[0]; $remoteFilename = $latestBackup['name'] ?? $latestBackup['file'] ?? ''; $remotePath = $latestBackup['path'] ?? "/home/{$this->cpanelUsername}/{$remoteFilename}"; $currentSize = (int) ($latestBackup['size'] ?? 0); if (empty($remoteFilename)) { $this->addStatusLog(__('Waiting for backup file... (check :count)', ['count' => $this->pollCount]), 'pending'); return; } // Check if file size has stabilized (backup still being written) // Require size to be stable AND at least 100 KB (real backups are much larger) $minBackupSize = 100 * 1024; // 100 KB minimum if ($this->lastSeenBackupSize !== $currentSize || $currentSize < $minBackupSize) { $this->lastSeenBackupSize = $currentSize; $this->addStatusLog(__('Backup in progress... :size (check :count)', [ 'size' => $this->formatBytes($currentSize), 'count' => $this->pollCount, ]), 'pending'); return; } // Size is stable and large enough - backup is complete $this->addStatusLog(__('Backup complete on cPanel: :name (:size)', [ 'name' => $remoteFilename, 'size' => $this->formatBytes($currentSize), ]), 'success'); $this->addStatusLog(__('Downloading backup file...'), 'pending'); // Download the backup $localPath = $destPath.'/'.$remoteFilename; $downloadResult = $cpanel->downloadFileToPath($remotePath, $localPath, function ($downloaded, $total) { $percent = $total > 0 ? round(($downloaded / $total) * 100) : 0; $this->addStatusLog(__('Downloading... :percent% (:downloaded / :total)', [ 'percent' => $percent, 'downloaded' => $this->formatBytes($downloaded), 'total' => $this->formatBytes($total), ]), 'pending'); }); if (! ($downloadResult['success'] ?? false)) { throw new Exception($downloadResult['message'] ?? __('Download failed')); } // Verify the downloaded file is actually a gzip archive (not an HTML error page) $handle = fopen($localPath, 'rb'); $magic = $handle ? fread($handle, 2) : ''; if ($handle) { fclose($handle); } // Gzip magic bytes: 0x1f 0x8b if ($magic !== "\x1f\x8b") { @unlink($localPath); // Delete invalid file // Clean up any old/invalid backup files in the destination $oldFiles = glob($destPath.'/backup-*.tar.gz'); foreach ($oldFiles as $oldFile) { @unlink($oldFile); } $this->addStatusLog(__('HTTP download blocked (403 Forbidden). Switching to SCP...'), 'warning'); // Reset state and switch to SCP method $this->backupInitiated = false; $this->backupMethod = 'scp'; $this->lastSeenBackupSize = null; $this->pollCount = 0; // Trigger SCP transfer $this->startScpBackupTransfer(); return; } $this->backupPath = $localPath; $this->backupFilename = $remoteFilename; $this->backupSize = filesize($localPath); $this->addStatusLog(__('Backup downloaded successfully'), 'success'); // Always analyze backup to get accurate data (API may not have all permissions) $this->analyzeBackup(); Notification::make() ->title(__('Backup downloaded')) ->body(__('Backup file :name (:size) is ready. Click Next to continue.', [ 'name' => $this->backupFilename, 'size' => $this->formatBytes($this->backupSize), ])) ->success() ->send(); } catch (Exception $e) { Log::error('Backup download failed', ['error' => $e->getMessage()]); $this->addStatusLog(__('Download error: :message', ['message' => $e->getMessage()]), 'error'); Notification::make() ->title(__('Download failed')) ->body($e->getMessage()) ->danger() ->send(); } } protected function addStatusLog(string $message, string $status = 'info'): void { $lastIndex = count($this->statusLog) - 1; if ($lastIndex >= 0 && $this->statusLog[$lastIndex]['status'] === 'pending' && $status !== 'pending') { $this->statusLog[$lastIndex] = [ 'message' => $message, 'status' => $status, 'time' => now()->format('H:i:s'), ]; return; } if ($status === 'pending') { $this->statusLog = array_filter($this->statusLog, fn ($entry) => $entry['status'] !== 'pending' || ! str_contains($entry['message'], 'Waiting for backup')); $this->statusLog = array_values($this->statusLog); } $this->statusLog[] = [ 'message' => $message, 'status' => $status, 'time' => now()->format('H:i:s'), ]; } protected function cleanupCpanelSshKey(): void { try { $keyName = 'jabali-system-key'; $cpanel = $this->getCpanel(); $cpanel->deleteSshKey($keyName, 'key'); $cpanel->deleteSshKey($keyName, 'key.pub'); $this->addStatusLog(__('SSH key removed from cPanel'), 'success'); } catch (Exception $e) { Log::warning('Failed to cleanup cPanel SSH key: '.$e->getMessage()); } } public function analyzeBackup(): void { if (! $this->backupPath) { return; } $this->addStatusLog(__('Analyzing backup contents...'), 'pending'); try { $result = $this->getAgent()->send('cpanel.analyze_backup', [ 'backup_path' => $this->backupPath, ]); if ($result['success'] ?? false) { $this->discoveredData = $result['data'] ?? []; $this->addStatusLog(__('Backup analyzed: :domains domains, :dbs databases, :mailboxes mailboxes', [ 'domains' => count($this->discoveredData['domains'] ?? []), 'dbs' => count($this->discoveredData['databases'] ?? []), 'mailboxes' => count($this->discoveredData['mailboxes'] ?? []), ]), 'success'); } else { throw new Exception($result['error'] ?? __('Failed to analyze backup')); } } catch (Exception $e) { Log::error('Backup analysis failed', ['error' => $e->getMessage()]); $this->addStatusLog(__('Analysis error: :message', ['message' => $e->getMessage()]), 'error'); } } public function analyzeLocalBackup(): void { if (! $this->backupPath) { Notification::make() ->title(__('No backup file')) ->body(__('Please validate a backup file first')) ->danger() ->send(); return; } // Reset and start analysis $this->analysisLog = []; $this->isAnalyzing = true; $this->discoveredData = []; $this->addAnalysisLog(__('Starting backup analysis...'), 'pending'); try { // Step 1: Extracting backup $this->addAnalysisLog(__('Extracting backup archive...'), 'pending'); $result = $this->getAgent()->send('cpanel.analyze_backup', [ 'backup_path' => $this->backupPath, ]); if ($result['success'] ?? false) { $this->addAnalysisLog(__('Backup archive extracted'), 'success'); $this->discoveredData = $result['data'] ?? []; // Extract cPanel username from backup analysis (needed for user creation) if (! empty($this->discoveredData['cpanel_username'])) { $this->cpanelUsername = $this->discoveredData['cpanel_username']; $this->addAnalysisLog(__('cPanel user: :user', ['user' => $this->cpanelUsername]), 'success'); } // Show what was found $domainCount = count($this->discoveredData['domains'] ?? []); $dbCount = count($this->discoveredData['databases'] ?? []); $mailCount = count($this->discoveredData['mailboxes'] ?? []); $sslCount = count($this->discoveredData['ssl_certificates'] ?? []); if ($domainCount > 0) { $this->addAnalysisLog(__('Found :count domain(s)', ['count' => $domainCount]), 'success'); } if ($dbCount > 0) { $this->addAnalysisLog(__('Found :count database(s)', ['count' => $dbCount]), 'success'); } if ($mailCount > 0) { $this->addAnalysisLog(__('Found :count mailbox(es)', ['count' => $mailCount]), 'success'); } if ($sslCount > 0) { $this->addAnalysisLog(__('Found :count SSL certificate(s)', ['count' => $sslCount]), 'success'); } $this->addAnalysisLog(__('Analysis complete'), 'success'); Notification::make() ->title(__('Analysis complete')) ->body(__('Found :domains domains, :dbs databases, :mailboxes mailboxes. Click Next to continue.', [ 'domains' => $domainCount, 'dbs' => $dbCount, 'mailboxes' => $mailCount, ])) ->success() ->send(); } else { throw new Exception($result['error'] ?? __('Failed to analyze backup')); } } catch (Exception $e) { Log::error('Local backup analysis failed', ['error' => $e->getMessage()]); $this->addAnalysisLog(__('Error: :message', ['message' => $e->getMessage()]), 'error'); Notification::make() ->title(__('Analysis failed')) ->body($e->getMessage()) ->danger() ->send(); } finally { $this->isAnalyzing = false; } } public function startRestore(): void { if (! $this->backupPath) { return; } $this->isProcessing = true; $this->migrationLog = []; $this->restoreStatus = 'queued'; // Get or create the target user $user = null; if ($this->userMode === 'create') { $this->addLog(__('Creating new user from cPanel backup...'), 'pending'); $user = $this->createUserFromBackup(); if (! $user) { Notification::make() ->title(__('User creation failed')) ->body(__('Could not create user from backup')) ->danger() ->send(); $this->isProcessing = false; return; } } else { $user = $this->getTargetUser(); if (! $user) { Notification::make() ->title(__('No user selected')) ->body(__('Please select a target user')) ->danger() ->send(); $this->isProcessing = false; return; } } $this->enqueueRestore($user); } protected function addLog(string $message, string $status = 'info'): void { $this->migrationLog[] = [ 'message' => $message, 'status' => $status, 'time' => now()->format('H:i:s'), ]; } protected function enqueueRestore(User $user): void { $this->restoreJobId = (string) Str::uuid(); $logDir = storage_path('app/migrations/cpanel'); File::ensureDirectoryExists($logDir); $this->restoreLogPath = $logDir.'/'.$this->restoreJobId.'.log'; File::put($this->restoreLogPath, ''); @chmod($this->restoreLogPath, 0644); $this->appendMigrationLog(__('Restore queued for user: :user', ['user' => $user->username]), 'pending'); Cache::put($this->getRestoreCacheKey(), ['status' => 'queued'], now()->addHours(2)); session()->put('cpanel_restore_job_id', $this->restoreJobId); session()->put('cpanel_restore_log_path', $this->restoreLogPath); session()->put('cpanel_restore_processing', true); session()->save(); RunCpanelRestore::dispatch( jobId: $this->restoreJobId, logPath: $this->restoreLogPath, backupPath: $this->backupPath, username: $user->username, restoreFiles: $this->restoreFiles, restoreDatabases: $this->restoreDatabases, restoreEmails: $this->restoreEmails, restoreSsl: $this->restoreSsl, discoveredData: ! empty($this->discoveredData) ? $this->discoveredData : null, ); } public function pollMigrationLog(): void { if (! $this->restoreJobId || ! $this->restoreLogPath) { return; } $this->migrationLog = $this->readMigrationLog($this->restoreLogPath); $status = Cache::get($this->getRestoreCacheKey()); if (is_array($status)) { $this->restoreStatus = $status['status'] ?? $this->restoreStatus; } if (in_array($this->restoreStatus, ['completed', 'failed'], true)) { $this->isProcessing = false; session()->forget(['cpanel_restore_job_id', 'cpanel_restore_log_path', 'cpanel_restore_processing']); session()->save(); } } protected function readMigrationLog(string $path): array { if (! file_exists($path)) { return []; } $entries = []; $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); foreach ($lines as $line) { $decoded = json_decode($line, true); if (is_array($decoded) && isset($decoded['message'], $decoded['status'])) { $entries[] = [ 'message' => $decoded['message'], 'status' => $decoded['status'], 'time' => $decoded['time'] ?? now()->format('H:i:s'), ]; } } return $entries; } protected function appendMigrationLog(string $message, string $status): void { $entry = [ 'message' => $message, 'status' => $status, 'time' => now()->format('H:i:s'), ]; $this->migrationLog[] = $entry; if ($this->restoreLogPath) { file_put_contents( $this->restoreLogPath, json_encode($entry).PHP_EOL, FILE_APPEND | LOCK_EX ); @chmod($this->restoreLogPath, 0644); } } protected function getRestoreCacheKey(): string { return 'cpanel_restore_status_'.$this->restoreJobId; } protected function restoreMigrationStateFromSession(): void { $this->restoreJobId = session()->get('cpanel_restore_job_id'); $this->restoreLogPath = session()->get('cpanel_restore_log_path'); $this->isProcessing = (bool) session()->get('cpanel_restore_processing', false); if ($this->restoreJobId && $this->restoreLogPath) { $this->pollMigrationLog(); } } public function resetMigration(): void { $this->userMode = 'create'; $this->targetUserId = null; $this->sourceType = 'remote'; $this->localBackupPath = null; $this->hostname = null; $this->cpanelUsername = null; $this->apiToken = null; $this->port = 2083; $this->useSSL = true; $this->isConnected = false; $this->connectionInfo = []; $this->backupInitiated = false; $this->backupPid = null; $this->backupFilename = null; $this->backupPath = null; $this->backupSize = 0; $this->pollCount = 0; $this->discoveredData = []; $this->restoreFiles = true; $this->restoreDatabases = true; $this->restoreEmails = true; $this->restoreSsl = true; $this->isProcessing = false; $this->isAnalyzing = false; $this->migrationLog = []; $this->statusLog = []; $this->analysisLog = []; $this->cpanel = null; $this->restoreJobId = null; $this->restoreLogPath = null; $this->restoreStatus = null; // Clear session credentials $this->clearSessionCredentials(); session()->forget(['cpanel_restore_job_id', 'cpanel_restore_log_path', 'cpanel_restore_processing']); session()->save(); $this->redirect(static::getUrl()); } protected function formatBytes(int $bytes, int $precision = 2): string { $units = ['B', 'KB', 'MB', 'GB', 'TB']; $bytes = max($bytes, 0); $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); $pow = min($pow, count($units) - 1); $bytes /= pow(1024, $pow); return round($bytes, $precision).' '.$units[$pow]; } }