Files
jabali-panel/app/Filament/Admin/Pages/CpanelMigration.php
2026-02-06 02:47:43 +00:00

2418 lines
92 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Jobs\RunCpanelRestore;
use App\Models\User;
use App\Services\Agent\AgentClient;
use App\Services\Migration\CpanelApiService;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Radio;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Infolists\Concerns\InteractsWithInfolists;
use Filament\Infolists\Contracts\HasInfolists;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Actions as FormActions;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Text;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Wizard;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Livewire\Attributes\Url;
class CpanelMigration extends Page implements HasActions, HasForms, HasInfolists, HasTable
{
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithInfolists;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-down-tray';
protected static ?string $navigationLabel = null;
protected static bool $shouldRegisterNavigation = false;
public static function getNavigationLabel(): string
{
return __('cPanel Migration');
}
protected static ?int $navigationSort = 21;
public static function getNavigationGroup(): string
{
return __('Server');
}
protected string $view = 'filament.admin.pages.cpanel-migration';
// Track wizard step via URL (synced with Filament's persistStepInQueryString)
#[Url(as: 'cpanel-step')]
public ?string $wizardStep = null;
// Track step completion for proper Next button state
public bool $step1Complete = false;
// Current active tab for data display
public string $activeDataTab = 'domains';
// User mode: 'create' (new user from backup) or 'existing' (select existing user)
public string $userMode = 'create';
// User selection (admin-only feature, used when userMode is 'existing')
public ?int $targetUserId = null;
// Source type: 'remote' (cPanel API) or 'local' (file system)
public string $sourceType = 'remote';
// Local backup path (when sourceType is 'local')
public ?string $localBackupPath = null;
// Connection form (Step 1 - remote mode)
public ?string $hostname = null;
public ?string $cpanelUsername = null;
public ?string $apiToken = null;
public int $port = 2083;
public bool $useSSL = true;
// Connection status
public bool $isConnected = false;
public array $connectionInfo = [];
// Full API summary data (for skipping backup analysis)
public array $apiSummary = [];
// Backup status (Step 2)
public bool $backupInitiated = false;
public ?string $backupPid = null;
public ?string $backupFilename = null;
public ?string $backupPath = null;
public int $backupSize = 0;
public string $backupMethod = 'download'; // 'download' or 'scp'
public ?int $backupInitiatedAt = null; // Unix timestamp when backup was started
public ?int $lastSeenBackupSize = null; // Track file size between polls
public int $pollCount = 0;
// Discovered data from backup
public array $discoveredData = [];
// Restore options (Step 3)
public bool $restoreFiles = true;
public bool $restoreDatabases = true;
public bool $restoreEmails = true;
public bool $restoreSsl = true;
// Status
public bool $isProcessing = false;
public bool $isAnalyzing = false;
public array $statusLog = [];
public array $analysisLog = [];
public array $migrationLog = [];
public ?string $restoreJobId = null;
public ?string $restoreLogPath = null;
public ?string $restoreStatus = null;
protected ?AgentClient $agent = null;
protected ?CpanelApiService $cpanel = null;
public function getTitle(): string|Htmlable
{
return __('cPanel Migration');
}
public function getSubheading(): ?string
{
return __('Migrate complete cPanel accounts to Jabali users');
}
protected function getHeaderActions(): array
{
return [
Action::make('startOver')
->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];
}
}