Compare commits

...

12 Commits

36 changed files with 3210 additions and 1994 deletions

6
.stylelintignore Normal file
View File

@@ -0,0 +1,6 @@
vendor/
node_modules/
public/build/
public/vendor/
public/fonts/
public/css/filament/

18
.stylelintrc.json Normal file
View File

@@ -0,0 +1,18 @@
{
"rules": {
"at-rule-no-unknown": [
true,
{
"ignoreAtRules": [
"tailwind",
"apply",
"layer",
"variants",
"responsive",
"screen",
"theme"
]
}
]
}
}

View File

@@ -5,7 +5,7 @@
A modern web hosting control panel for WordPress and general PHP hosting. Jabali focuses on clean multi-tenant isolation, safe automation, and a consistent admin/user experience. It ships with a privileged agent for root-level tasks, built-in mail and DNS management, migrations from common panels, and a security center that keeps critical services in check. The UI is designed to be fast, predictable, and easy to operate on a single server.
Version: 0.9-rc51 (release candidate)
Version: see `VERSION` (release candidate)
This is a release candidate. Expect rapid iteration and breaking changes until 1.0.
@@ -27,6 +27,25 @@ This is a release candidate. Expect rapid iteration and breaking changes until 1
- Security center with firewall, Fail2ban, ClamAV, and scanners
- Audit logs and admin notifications
## Screenshots
Admin panel:
- [Admin Dashboard](docs/screenshots/admin-dashboard.png)
- [Admin Server Status](docs/screenshots/admin-server-status.png)
- [Admin Server Settings](docs/screenshots/admin-server-settings.png)
- [Admin Security](docs/screenshots/admin-security.png)
- [Admin Users](docs/screenshots/admin-users.png)
- [Admin SSL Manager](docs/screenshots/admin-ssl-manager.png)
- [Admin DNS Zones](docs/screenshots/admin-dns-zones.png)
- [Admin Backups](docs/screenshots/admin-backups.png)
- [Admin Services](docs/screenshots/admin-services.png)
User panel:
- [User Dashboard](docs/screenshots/user-dashboard.png)
- [User Domains](docs/screenshots/user-domains.png)
- [User Backups](docs/screenshots/user-backups.png)
- [User cPanel Migration](docs/screenshots/user-cpanel-migration.png)
## Installation
GitHub install:

View File

@@ -1 +1 @@
VERSION=0.9-rc60
VERSION=0.9-rc61

View File

@@ -1,235 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BackupSchedule extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'destination_id',
'name',
'is_active',
'is_server_backup',
'frequency',
'time',
'day_of_week',
'day_of_month',
'include_files',
'include_databases',
'include_mailboxes',
'include_dns',
'domains',
'databases',
'mailboxes',
'users',
'retention_count',
'last_run_at',
'next_run_at',
'last_status',
'last_error',
'metadata',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'is_server_backup' => 'boolean',
'include_files' => 'boolean',
'include_databases' => 'boolean',
'include_mailboxes' => 'boolean',
'include_dns' => 'boolean',
'domains' => 'array',
'databases' => 'array',
'mailboxes' => 'array',
'users' => 'array',
'metadata' => 'array',
'retention_count' => 'integer',
'day_of_week' => 'integer',
'day_of_month' => 'integer',
'last_run_at' => 'datetime',
'next_run_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function destination(): BelongsTo
{
return $this->belongsTo(BackupDestination::class, 'destination_id');
}
public function backups(): HasMany
{
return $this->hasMany(Backup::class, 'schedule_id');
}
/**
* Check if the schedule should run now.
*/
public function shouldRun(): bool
{
if (! $this->is_active) {
return false;
}
if (! $this->next_run_at) {
return true;
}
return $this->next_run_at->isPast();
}
/**
* Calculate and set the next run time.
*/
public function calculateNextRun(): Carbon
{
$timezone = $this->getSystemTimezone();
$now = Carbon::now($timezone);
$time = explode(':', $this->time);
$hour = (int) ($time[0] ?? 2);
$minute = (int) ($time[1] ?? 0);
$next = $now->copy()->setTime($hour, $minute, 0);
// If time already passed today, start from tomorrow
if ($next->isPast()) {
$next->addDay();
}
switch ($this->frequency) {
case 'hourly':
$next = $now->copy()->addHour()->startOfHour();
break;
case 'daily':
// Already set to next occurrence
break;
case 'weekly':
$targetDay = $this->day_of_week ?? 0; // Default to Sunday
while ($next->dayOfWeek !== $targetDay) {
$next->addDay();
}
break;
case 'monthly':
$targetDay = $this->day_of_month ?? 1;
$next->day = min($targetDay, $next->daysInMonth);
if ($next->isPast()) {
$next->addMonth();
$next->day = min($targetDay, $next->daysInMonth);
}
break;
}
$nextUtc = $next->copy()->setTimezone('UTC');
$this->attributes['next_run_at'] = $nextUtc->format($this->getDateFormat());
return $nextUtc;
}
/**
* Get frequency label for UI.
*/
public function getFrequencyLabelAttribute(): string
{
$base = match ($this->frequency) {
'hourly' => 'Every hour',
'daily' => 'Daily at '.$this->time,
'weekly' => 'Weekly on '.$this->getDayName().' at '.$this->time,
'monthly' => 'Monthly on day '.($this->day_of_month ?? 1).' at '.$this->time,
default => ucfirst($this->frequency),
};
return $base;
}
/**
* Get day name for weekly schedules.
*/
protected function getDayName(): string
{
$days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
return $days[$this->day_of_week ?? 0];
}
protected function getSystemTimezone(): string
{
static $timezone = null;
if ($timezone === null) {
$timezone = trim((string) @file_get_contents('/etc/timezone'));
if ($timezone === '') {
$timezone = trim((string) @shell_exec('timedatectl show -p Timezone --value 2>/dev/null'));
}
if ($timezone === '') {
$timezone = 'UTC';
}
}
return $timezone;
}
/**
* Scope for active schedules.
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Scope for due schedules.
*/
public function scopeDue($query)
{
return $query->active()
->where(function ($q) {
$q->whereNull('next_run_at')
->orWhere('next_run_at', '<=', now());
});
}
/**
* Scope for user schedules.
*/
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
/**
* Scope for server backup schedules.
*/
public function scopeServerBackups($query)
{
return $query->where('is_server_backup', true);
}
/**
* Get last status color for UI.
*/
public function getLastStatusColorAttribute(): string
{
return match ($this->last_status) {
'success' => 'success',
'failed' => 'danger',
default => 'gray',
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ use Illuminate\Support\Str;
class ImportProcessCommand extends Command
{
protected $signature = 'import:process {import_id : The server import ID to process}';
protected $description = 'Process a server import job (cPanel/DirectAdmin migration)';
private ?AgentClient $agent = null;
@@ -27,8 +28,9 @@ class ImportProcessCommand extends Command
$importId = (int) $this->argument('import_id');
$import = ServerImport::with('accounts')->find($importId);
if (!$import) {
if (! $import) {
$this->error("Import not found: $importId");
return 1;
}
@@ -43,6 +45,7 @@ class ImportProcessCommand extends Command
'current_task' => null,
]);
$import->addError('No accounts selected for import');
return 1;
}
@@ -67,8 +70,8 @@ class ImportProcessCommand extends Command
'status' => 'failed',
'error' => $e->getMessage(),
]);
$account->addLog("Import failed: " . $e->getMessage());
$import->addError("Account {$account->source_username}: " . $e->getMessage());
$account->addLog('Import failed: '.$e->getMessage());
$import->addError("Account {$account->source_username}: ".$e->getMessage());
}
}
@@ -96,10 +99,10 @@ class ImportProcessCommand extends Command
'completed_at' => now(),
'progress' => 100,
]);
$import->addLog("All accounts imported successfully");
$import->addLog('All accounts imported successfully');
}
$this->info("Import completed. Success: " . ($totalAccounts - $failedCount) . ", Failed: $failedCount");
$this->info('Import completed. Success: '.($totalAccounts - $failedCount).", Failed: $failedCount");
return 0;
}
@@ -107,8 +110,9 @@ class ImportProcessCommand extends Command
private function getAgent(): AgentClient
{
if ($this->agent === null) {
$this->agent = new AgentClient();
$this->agent = new AgentClient;
}
return $this->agent;
}
@@ -132,28 +136,28 @@ class ImportProcessCommand extends Command
if ($account->main_domain) {
$account->update(['current_task' => 'Creating domains...', 'progress' => 20]);
$this->createDomains($account, $user);
$account->addLog("Created domains");
$account->addLog('Created domains');
}
// Step 3: Import files
if ($options['files'] ?? true) {
$account->update(['current_task' => 'Importing files...', 'progress' => 40]);
$this->importFiles($import, $account, $user);
$account->addLog("Files imported");
$account->addLog('Files imported');
}
// Step 4: Import databases
if (($options['databases'] ?? true) && !empty($account->databases)) {
if (($options['databases'] ?? true) && ! empty($account->databases)) {
$account->update(['current_task' => 'Importing databases...', 'progress' => 60]);
$this->importDatabases($import, $account, $user);
$account->addLog("Databases imported");
$account->addLog('Databases imported');
}
// Step 5: Import emails
if (($options['emails'] ?? true) && !empty($account->email_accounts)) {
if (($options['emails'] ?? true) && ! empty($account->email_accounts)) {
$account->update(['current_task' => 'Importing email accounts...', 'progress' => 80]);
$this->importEmails($import, $account, $user);
$account->addLog("Email accounts imported");
$account->addLog('Email accounts imported');
}
$account->update([
@@ -161,7 +165,7 @@ class ImportProcessCommand extends Command
'progress' => 100,
'current_task' => null,
]);
$account->addLog("Import completed successfully");
$account->addLog('Import completed successfully');
}
private function createUser(ServerImportAccount $account): User
@@ -170,6 +174,7 @@ class ImportProcessCommand extends Command
$existingUser = User::where('username', $account->target_username)->first();
if ($existingUser) {
$account->addLog("User already exists: {$account->target_username}");
return $existingUser;
}
@@ -179,8 +184,8 @@ class ImportProcessCommand extends Command
// Create user via agent
$result = $this->getAgent()->createUser($account->target_username, $password);
if (!($result['success'] ?? false)) {
throw new Exception("Failed to create system user: " . ($result['error'] ?? 'Unknown error'));
if (! ($result['success'] ?? false)) {
throw new Exception('Failed to create system user: '.($result['error'] ?? 'Unknown error'));
}
// Create user in database
@@ -191,7 +196,7 @@ class ImportProcessCommand extends Command
'password' => Hash::make($password),
]);
$account->addLog("Created user with temporary password. User should reset password.");
$account->addLog('Created user with temporary password. User should reset password.');
return $user;
}
@@ -201,7 +206,7 @@ class ImportProcessCommand extends Command
// Create main domain
if ($account->main_domain) {
$existingDomain = Domain::where('domain', $account->main_domain)->first();
if (!$existingDomain) {
if (! $existingDomain) {
$result = $this->getAgent()->domainCreate($user->username, $account->main_domain);
if ($result['success'] ?? false) {
@@ -213,7 +218,7 @@ class ImportProcessCommand extends Command
]);
$account->addLog("Created main domain: {$account->main_domain}");
} else {
$account->addLog("Warning: Failed to create main domain: " . ($result['error'] ?? 'Unknown'));
$account->addLog('Warning: Failed to create main domain: '.($result['error'] ?? 'Unknown'));
}
} else {
$account->addLog("Main domain already exists: {$account->main_domain}");
@@ -223,7 +228,7 @@ class ImportProcessCommand extends Command
// Create addon domains
foreach ($account->addon_domains ?? [] as $domain) {
$existingDomain = Domain::where('domain', $domain)->first();
if (!$existingDomain) {
if (! $existingDomain) {
$result = $this->getAgent()->domainCreate($user->username, $domain);
if ($result['success'] ?? false) {
@@ -243,31 +248,38 @@ class ImportProcessCommand extends Command
private function importFiles(ServerImport $import, ServerImportAccount $account, User $user): void
{
if ($import->import_method !== 'backup_file' || !$import->backup_path) {
$account->addLog("File import skipped - not a backup file import");
if ($import->import_method !== 'backup_file' || ! $import->backup_path) {
$account->addLog('File import skipped - not a backup file import');
return;
}
$backupPath = Storage::disk('local')->path($import->backup_path);
if (!file_exists($backupPath)) {
$account->addLog("Warning: Backup file not found");
$backupPath = $this->resolveBackupFullPath($import);
if (! $backupPath) {
$account->addLog('Warning: Backup file not found');
return;
}
$extractDir = "/tmp/import_{$import->id}_{$account->id}_" . time();
if (!mkdir($extractDir, 0755, true)) {
$account->addLog("Warning: Failed to create extraction directory");
$extractDir = "/tmp/import_{$import->id}_{$account->id}_".time();
if (! mkdir($extractDir, 0755, true)) {
$account->addLog('Warning: Failed to create extraction directory');
return;
}
try {
$username = $account->source_username;
$tarExtract = $this->getTarExtractCommandPrefix($backupPath);
if ($import->source_type === 'cpanel') {
// Extract home directory from cPanel backup
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
$cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
" --wildcards '*/{$username}/homedir/*' '*/homedir/*' 2>/dev/null";
exec($cmd, $output, $code);
if ($code !== 0) {
$account->addLog('Warning: Failed to extract backup archive');
}
// Find extracted files
$homeDirs = glob("$extractDir/**/homedir", GLOB_ONLYDIR) ?:
@@ -280,17 +292,20 @@ class ImportProcessCommand extends Command
if (is_dir($publicHtml) && $account->main_domain) {
$destDir = "/home/{$user->username}/domains/{$account->main_domain}/public";
if (is_dir($destDir)) {
exec("cp -r " . escapeshellarg($publicHtml) . "/* " . escapeshellarg($destDir) . "/ 2>&1");
exec("chown -R " . escapeshellarg($user->username) . ":" . escapeshellarg($user->username) . " " . escapeshellarg($destDir) . " 2>&1");
exec('cp -r '.escapeshellarg($publicHtml).'/* '.escapeshellarg($destDir).'/ 2>&1');
exec('chown -R '.escapeshellarg($user->username).':'.escapeshellarg($user->username).' '.escapeshellarg($destDir).' 2>&1');
$account->addLog("Copied public_html to {$account->main_domain}");
}
}
}
} else {
// Extract from DirectAdmin backup
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
$cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
" --wildcards 'domains/*' 'backup/domains/*' 2>/dev/null";
exec($cmd, $output, $code);
if ($code !== 0) {
$account->addLog('Warning: Failed to extract DirectAdmin backup archive');
}
// Find domain directories
$domainDirs = glob("$extractDir/**/domains/*", GLOB_ONLYDIR) ?:
@@ -303,8 +318,8 @@ class ImportProcessCommand extends Command
if (is_dir($publicHtml)) {
$destDir = "/home/{$user->username}/domains/{$domain}/public";
if (is_dir($destDir)) {
exec("cp -r " . escapeshellarg($publicHtml) . "/* " . escapeshellarg($destDir) . "/ 2>&1");
exec("chown -R " . escapeshellarg($user->username) . ":" . escapeshellarg($user->username) . " " . escapeshellarg($destDir) . " 2>&1");
exec('cp -r '.escapeshellarg($publicHtml).'/* '.escapeshellarg($destDir).'/ 2>&1');
exec('chown -R '.escapeshellarg($user->username).':'.escapeshellarg($user->username).' '.escapeshellarg($destDir).' 2>&1');
$account->addLog("Copied files for domain: {$domain}");
}
}
@@ -312,67 +327,115 @@ class ImportProcessCommand extends Command
}
} finally {
// Cleanup
exec("rm -rf " . escapeshellarg($extractDir));
exec('rm -rf '.escapeshellarg($extractDir));
}
}
private function importDatabases(ServerImport $import, ServerImportAccount $account, User $user): void
{
if ($import->import_method !== 'backup_file' || !$import->backup_path) {
$account->addLog("Database import skipped - not a backup file import");
if ($import->import_method !== 'backup_file' || ! $import->backup_path) {
$account->addLog('Database import skipped - not a backup file import');
return;
}
$backupPath = Storage::disk('local')->path($import->backup_path);
if (!file_exists($backupPath)) {
$backupPath = $this->resolveBackupFullPath($import);
if (! $backupPath) {
return;
}
$extractDir = "/tmp/import_db_{$import->id}_{$account->id}_" . time();
if (!mkdir($extractDir, 0755, true)) {
$extractDir = "/tmp/import_db_{$import->id}_{$account->id}_".time();
if (! mkdir($extractDir, 0755, true)) {
return;
}
try {
$tarExtract = $this->getTarExtractCommandPrefix($backupPath);
// Extract MySQL dumps
if ($import->source_type === 'cpanel') {
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
" --wildcards '*/mysql/*.sql' 'mysql/*.sql' 2>/dev/null";
$cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
" --wildcards '*/mysql/*.sql*' 'mysql/*.sql*' 2>/dev/null";
} else {
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
" --wildcards 'backup/databases/*.sql' 'databases/*.sql' 2>/dev/null";
$cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
" --wildcards 'backup/databases/*.sql*' 'databases/*.sql*' 2>/dev/null";
}
exec($cmd, $output, $code);
if ($code !== 0) {
$account->addLog('Warning: Failed to extract database dumps from backup archive');
}
// Find SQL files
$sqlFiles = [];
exec("find " . escapeshellarg($extractDir) . " -name '*.sql' -type f 2>/dev/null", $sqlFiles);
exec('find '.escapeshellarg($extractDir)." -type f \\( -name '*.sql' -o -name '*.sql.gz' -o -name '*.sql.zst' \\) 2>/dev/null", $sqlFiles);
foreach ($sqlFiles as $sqlFile) {
$dbName = basename($sqlFile, '.sql');
$fileName = basename($sqlFile);
$dbName = preg_replace('/\\.(sql|sql\\.gz|sql\\.zst)$/i', '', $fileName);
if (! is_string($dbName) || $dbName === '') {
continue;
}
// Create database name with user prefix
$newDbName = substr($user->username . '_' . preg_replace('/^[^_]+_/', '', $dbName), 0, 64);
$newDbName = substr($user->username.'_'.preg_replace('/^[^_]+_/', '', $dbName), 0, 64);
// Create database via agent
$result = $this->getAgent()->mysqlCreateDatabase($user->username, $newDbName);
if ($result['success'] ?? false) {
// Import data
$cmd = "mysql " . escapeshellarg($newDbName) . " < " . escapeshellarg($sqlFile) . " 2>&1";
exec($cmd, $importOutput, $importCode);
$sqlToImport = $sqlFile;
$tmpSql = null;
if ($importCode === 0) {
$account->addLog("Imported database: {$newDbName}");
} else {
$account->addLog("Warning: Database created but import failed: {$newDbName}");
try {
$lower = strtolower($sqlFile);
if (str_ends_with($lower, '.sql.gz')) {
$tmpSql = $extractDir.'/import_'.$account->id.'_'.$dbName.'_'.uniqid('', true).'.sql';
$decompressCmd = 'gzip -dc '.escapeshellarg($sqlFile).' > '.escapeshellarg($tmpSql).' 2>/dev/null';
exec($decompressCmd, $decompressOutput, $decompressCode);
if ($decompressCode !== 0) {
$account->addLog("Warning: Failed to decompress database dump: {$fileName}");
continue;
}
$sqlToImport = $tmpSql;
} elseif (str_ends_with($lower, '.sql.zst')) {
$tmpSql = $extractDir.'/import_'.$account->id.'_'.$dbName.'_'.uniqid('', true).'.sql';
$decompressCmd = 'zstd -dc '.escapeshellarg($sqlFile).' > '.escapeshellarg($tmpSql).' 2>/dev/null';
exec($decompressCmd, $decompressOutput, $decompressCode);
if ($decompressCode !== 0) {
$account->addLog("Warning: Failed to decompress database dump: {$fileName}");
continue;
}
$sqlToImport = $tmpSql;
}
// Import data
$cmd = 'mysql '.escapeshellarg($newDbName).' < '.escapeshellarg($sqlToImport).' 2>&1';
exec($cmd, $importOutput, $importCode);
if ($importCode === 0) {
$account->addLog("Imported database: {$newDbName}");
} else {
$account->addLog("Warning: Database created but import failed: {$newDbName}");
}
} finally {
if ($tmpSql && file_exists($tmpSql)) {
@unlink($tmpSql);
}
}
} else {
$account->addLog("Warning: Failed to create database: {$newDbName}");
}
}
} finally {
exec("rm -rf " . escapeshellarg($extractDir));
exec('rm -rf '.escapeshellarg($extractDir));
}
}
@@ -384,6 +447,45 @@ class ImportProcessCommand extends Command
$account->addLog("Email account found (not imported): {$emailAccount}@{$account->main_domain}");
}
$account->addLog("Note: Email accounts must be recreated manually");
$account->addLog('Note: Email accounts must be recreated manually');
}
private function resolveBackupFullPath(ServerImport $import): ?string
{
$path = trim((string) ($import->backup_path ?? ''));
if ($path === '') {
return null;
}
if (str_starts_with($path, '/') && file_exists($path)) {
return $path;
}
$localCandidate = Storage::disk('local')->path($path);
if (file_exists($localCandidate)) {
return $localCandidate;
}
$backupCandidate = Storage::disk('backups')->path($path);
if (file_exists($backupCandidate)) {
return $backupCandidate;
}
return file_exists($path) ? $path : null;
}
private function getTarExtractCommandPrefix(string $archivePath): string
{
$archivePath = strtolower($archivePath);
if (str_ends_with($archivePath, '.tar.zst') || str_ends_with($archivePath, '.zst')) {
return 'tar --zstd -xf';
}
if (str_ends_with($archivePath, '.tar.gz') || str_ends_with($archivePath, '.tgz')) {
return 'tar -xzf';
}
return 'tar -xf';
}
}

View File

@@ -0,0 +1,779 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Models\ServerImport;
use App\Models\ServerImportAccount;
use App\Services\Agent\AgentClient;
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\FileUpload;
use Filament\Forms\Components\Radio;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
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\Text;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\View;
use Filament\Schemas\Components\Wizard;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Url;
class DirectAdminMigration extends Page implements HasActions, HasForms
{
use InteractsWithActions;
use InteractsWithForms;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-down-tray';
protected static ?string $navigationLabel = null;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'directadmin-migration';
protected string $view = 'filament.admin.pages.directadmin-migration';
#[Url(as: 'directadmin-step')]
public ?string $wizardStep = null;
public bool $step1Complete = false;
public ?int $importId = null;
public ?string $name = null;
public string $importMethod = 'remote_server'; // remote_server|backup_file
public ?string $remoteHost = null;
public int $remotePort = 2222;
public ?string $remoteUser = null;
public ?string $remotePassword = null;
public ?string $backupPath = null;
public ?string $backupFilePath = null;
public bool $importFiles = true;
public bool $importDatabases = true;
public bool $importEmails = true;
public bool $importSsl = true;
protected ?AgentClient $agent = null;
public static function getNavigationLabel(): string
{
return __('DirectAdmin Migration');
}
public function getTitle(): string|Htmlable
{
return __('DirectAdmin Migration');
}
public function getSubheading(): ?string
{
return __('Migrate DirectAdmin accounts into Jabali');
}
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 the DirectAdmin migration wizard. Are you sure?'))
->action('resetMigration'),
];
}
public function mount(): void
{
$this->restoreFromSession();
$this->restoreFromImport();
}
protected function getForms(): array
{
return ['migrationForm'];
}
public function migrationForm(Schema $schema): Schema
{
return $schema->schema([
Wizard::make([
$this->getConnectStep(),
$this->getSelectAccountsStep(),
$this->getConfigureStep(),
$this->getMigrateStep(),
])
->persistStepInQueryString('directadmin-step'),
]);
}
protected function getConnectStep(): Step
{
return Step::make(__('Connect'))
->id('connect')
->icon('heroicon-o-link')
->description(__('Connect to DirectAdmin or upload a backup'))
->schema([
Section::make(__('Source'))
->description(__('Choose how you want to migrate DirectAdmin accounts.'))
->icon('heroicon-o-server')
->schema([
Grid::make(['default' => 1, 'sm' => 2])->schema([
TextInput::make('name')
->label(__('Import Name'))
->default(fn (): string => $this->name ?: ('DirectAdmin Import '.now()->format('Y-m-d H:i')))
->maxLength(255)
->required(),
Radio::make('importMethod')
->label(__('Import Method'))
->options([
'remote_server' => __('Remote Server'),
'backup_file' => __('Backup File'),
])
->default('remote_server')
->live(),
]),
Grid::make(['default' => 1, 'sm' => 2])
->schema([
TextInput::make('remoteHost')
->label(__('Host'))
->placeholder('directadmin.example.com')
->required()
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
TextInput::make('remotePort')
->label(__('Port'))
->numeric()
->default(2222)
->required()
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
TextInput::make('remoteUser')
->label(__('Username'))
->required()
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
TextInput::make('remotePassword')
->label(__('Password'))
->password()
->revealable()
->required()
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
]),
FileUpload::make('backupPath')
->label(__('DirectAdmin Backup Archive'))
->helperText(__('Upload a DirectAdmin backup archive (usually .tar.zst) to the server backups folder.'))
->disk('backups')
->directory('directadmin-migrations')
->preserveFilenames()
->visible(fn (Get $get): bool => $get('importMethod') === 'backup_file'),
TextInput::make('backupFilePath')
->label(__('Backup File Path'))
->placeholder('/var/backups/jabali/directadmin-migrations/user.tar.zst')
->helperText(__('Use this if the backup file already exists on the server.'))
->visible(fn (Get $get): bool => $get('importMethod') === 'backup_file'),
Text::make(__('Tip: Upload backups to /var/backups/jabali/directadmin-migrations/'))->color('gray')
->visible(fn (Get $get): bool => $get('importMethod') === 'backup_file'),
FormActions::make([
Action::make('discoverAccounts')
->label(__('Discover Accounts'))
->icon('heroicon-o-magnifying-glass')
->color('primary')
->action('discoverAccounts'),
])->alignEnd(),
]),
Section::make(__('Discovery'))
->description(__('Once accounts are discovered, proceed to select which ones to import.'))
->icon('heroicon-o-user-group')
->schema([
Text::make(__('Discovered accounts will appear in the next step.'))->color('gray'),
]),
])
->afterValidation(function () {
$import = $this->getImport();
$hasAccounts = $import?->accounts()->exists() ?? false;
if (! $hasAccounts) {
Notification::make()
->title(__('No accounts discovered'))
->body(__('Click "Discover Accounts" to continue.'))
->danger()
->send();
throw new Exception(__('No accounts discovered'));
}
$this->step1Complete = true;
$this->saveToSession();
});
}
protected function getSelectAccountsStep(): Step
{
return Step::make(__('Select Accounts'))
->id('accounts')
->icon('heroicon-o-users')
->description(__('Choose which DirectAdmin accounts to migrate'))
->schema([
Section::make(__('DirectAdmin Accounts'))
->description(fn (): string => $this->getAccountsStepDescription())
->icon('heroicon-o-user-group')
->headerActions([
Action::make('refreshAccounts')
->label(__('Refresh'))
->icon('heroicon-o-arrow-path')
->color('gray')
->action('refreshAccountsTable'),
Action::make('selectAll')
->label(__('Select All'))
->icon('heroicon-o-check')
->color('primary')
->action('selectAllAccounts')
->visible(fn (): bool => $this->getSelectedAccountsCount() < $this->getDiscoveredAccountsCount()),
Action::make('deselectAll')
->label(__('Deselect All'))
->icon('heroicon-o-x-mark')
->color('gray')
->action('deselectAllAccounts')
->visible(fn (): bool => $this->getSelectedAccountsCount() > 0),
])
->schema([
View::make('filament.admin.pages.directadmin-accounts-table'),
]),
])
->afterValidation(function () {
if ($this->getSelectedAccountsCount() === 0) {
Notification::make()
->title(__('No accounts selected'))
->body(__('Please select at least one account to migrate.'))
->danger()
->send();
throw new Exception(__('No accounts selected'));
}
$this->saveToSession();
});
}
protected function getConfigureStep(): Step
{
return Step::make(__('Configure'))
->id('configure')
->icon('heroicon-o-cog')
->description(__('Choose what to import and map accounts'))
->schema([
Section::make(__('What to Import'))
->description(__('Select which parts of each account to import.'))
->icon('heroicon-o-check-circle')
->schema([
Grid::make(['default' => 1, 'sm' => 2])->schema([
Checkbox::make('importFiles')
->label(__('Website Files'))
->helperText(__('Restore website files from the backup'))
->default(true),
Checkbox::make('importDatabases')
->label(__('Databases'))
->helperText(__('Restore MySQL databases and import dumps'))
->default(true),
Checkbox::make('importEmails')
->label(__('Email'))
->helperText(__('Create email domains and mailboxes (limited in Phase 1)'))
->default(true),
Checkbox::make('importSsl')
->label(__('SSL'))
->helperText(__('Install custom certificates or issue Let\'s Encrypt (Phase 3)'))
->default(true),
]),
]),
Section::make(__('Account Mappings'))
->description(fn (): string => __(':count account(s) selected', ['count' => $this->getSelectedAccountsCount()]))
->icon('heroicon-o-arrow-right')
->schema([
View::make('filament.admin.pages.directadmin-account-config-table'),
]),
])
->afterValidation(function (): void {
$import = $this->getImport();
if (! $import) {
throw new Exception(__('Import job not found'));
}
$import->update([
'import_options' => [
'files' => $this->importFiles,
'databases' => $this->importDatabases,
'emails' => $this->importEmails,
'ssl' => $this->importSsl,
],
]);
$this->saveToSession();
$this->dispatch('directadmin-config-updated');
});
}
protected function getMigrateStep(): Step
{
return Step::make(__('Migrate'))
->id('migrate')
->icon('heroicon-o-play')
->description(__('Run the migration and watch progress'))
->schema([
FormActions::make([
Action::make('startMigration')
->label(__('Start Migration'))
->icon('heroicon-o-play')
->color('success')
->requiresConfirmation()
->modalHeading(__('Start Migration'))
->modalDescription(__('This will migrate :count account(s). Continue?', ['count' => $this->getSelectedAccountsCount()]))
->action('startMigration'),
Action::make('newMigration')
->label(__('New Migration'))
->icon('heroicon-o-plus')
->color('primary')
->visible(fn (): bool => ($this->getImport()?->status ?? null) === 'completed')
->action('resetMigration'),
])->alignEnd(),
Section::make(__('Import Status'))
->icon('heroicon-o-queue-list')
->schema([
View::make('filament.admin.pages.directadmin-migration-status-table'),
]),
]);
}
public function discoverAccounts(): void
{
try {
$import = $this->upsertImportForDiscovery();
$backupFullPath = null;
$remotePassword = null;
if ($this->importMethod === 'backup_file') {
if (! $import->backup_path) {
throw new Exception(__('Please upload a DirectAdmin backup archive or enter its full path.'));
}
$backupFullPath = $this->resolveBackupFullPath($import->backup_path);
if (! $backupFullPath) {
throw new Exception(__('Backup file not found: :path', ['path' => $import->backup_path]));
}
} else {
$remotePassword = $this->remotePassword;
if (($remotePassword === null || $remotePassword === '') && filled($import->remote_password)) {
$remotePassword = (string) $import->remote_password;
}
if (! $import->remote_host || ! $import->remote_port || ! $import->remote_user || ! $remotePassword) {
throw new Exception(__('Please enter DirectAdmin host, port, username and password.'));
}
}
$result = $this->getAgent()->importDiscover(
$import->id,
'directadmin',
$import->import_method,
$backupFullPath,
$import->remote_host,
$import->remote_port ? (int) $import->remote_port : null,
$import->remote_user,
$remotePassword,
);
if (! ($result['success'] ?? false)) {
throw new Exception((string) ($result['error'] ?? __('Discovery failed')));
}
$accounts = $result['accounts'] ?? [];
if (! is_array($accounts) || $accounts === []) {
throw new Exception(__('No accounts were discovered.'));
}
$import->accounts()->delete();
$createdIds = [];
foreach ($accounts as $account) {
if (! is_array($account)) {
continue;
}
$username = trim((string) ($account['username'] ?? ''));
if ($username === '') {
continue;
}
$record = ServerImportAccount::create([
'server_import_id' => $import->id,
'source_username' => $username,
'target_username' => $username,
'email' => (string) ($account['email'] ?? ''),
'main_domain' => (string) ($account['main_domain'] ?? ''),
'addon_domains' => $account['addon_domains'] ?? [],
'subdomains' => $account['subdomains'] ?? [],
'databases' => $account['databases'] ?? [],
'email_accounts' => $account['email_accounts'] ?? [],
'disk_usage' => (int) ($account['disk_usage'] ?? 0),
'status' => 'pending',
'progress' => 0,
'current_task' => null,
'import_log' => [],
'error' => null,
]);
$createdIds[] = $record->id;
}
if ($createdIds === []) {
throw new Exception(__('No valid accounts were discovered.'));
}
$import->update([
'discovered_accounts' => $accounts,
'selected_accounts' => [],
'status' => 'ready',
'progress' => 0,
'current_task' => null,
'errors' => [],
]);
$this->importId = $import->id;
$this->step1Complete = true;
$this->saveToSession();
$this->dispatch('directadmin-accounts-updated');
Notification::make()
->title(__('Accounts discovered'))
->body(__('Found :count account(s).', ['count' => count($createdIds)]))
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(__('Discovery failed'))
->body($e->getMessage())
->danger()
->send();
}
}
protected function resolveBackupFullPath(?string $path): ?string
{
$path = trim((string) ($path ?? ''));
if ($path === '') {
return null;
}
if (str_starts_with($path, '/') && file_exists($path)) {
return $path;
}
$localCandidate = Storage::disk('local')->path($path);
if (file_exists($localCandidate)) {
return $localCandidate;
}
$backupCandidate = Storage::disk('backups')->path($path);
if (file_exists($backupCandidate)) {
return $backupCandidate;
}
return file_exists($path) ? $path : null;
}
public function selectAllAccounts(): void
{
$import = $this->getImport();
if (! $import) {
return;
}
$ids = $import->accounts()->pluck('id')->all();
$import->update(['selected_accounts' => $ids]);
$this->dispatch('directadmin-selection-updated');
}
public function deselectAllAccounts(): void
{
$import = $this->getImport();
if (! $import) {
return;
}
$import->update(['selected_accounts' => []]);
$this->dispatch('directadmin-selection-updated');
}
public function refreshAccountsTable(): void
{
$this->dispatch('directadmin-accounts-updated');
$this->dispatch('directadmin-config-updated');
}
public function startMigration(): void
{
$import = $this->getImport();
if (! $import) {
Notification::make()
->title(__('Import job not found'))
->danger()
->send();
return;
}
$selected = $import->selected_accounts ?? [];
if (! is_array($selected) || $selected === []) {
Notification::make()
->title(__('No accounts selected'))
->body(__('Please select at least one account to migrate.'))
->danger()
->send();
return;
}
if ($import->import_method === 'remote_server') {
Notification::make()
->title(__('Remote DirectAdmin import is not available yet'))
->body(__('For now, please download a DirectAdmin backup archive and use the "Backup File" method.'))
->warning()
->send();
return;
}
$import->update([
'status' => 'importing',
'started_at' => now(),
]);
$result = $this->getAgent()->importStart($import->id);
if (! ($result['success'] ?? false)) {
Notification::make()
->title(__('Failed to start migration'))
->body((string) ($result['error'] ?? __('Unknown error')))
->danger()
->send();
return;
}
Notification::make()
->title(__('Migration started'))
->body(__('Import process has started in the background.'))
->success()
->send();
}
public function resetMigration(): void
{
if ($this->importId) {
ServerImport::whereKey($this->importId)->delete();
}
session()->forget('directadmin_migration.import_id');
$this->wizardStep = null;
$this->step1Complete = false;
$this->importId = null;
$this->name = null;
$this->importMethod = 'remote_server';
$this->remoteHost = null;
$this->remotePort = 2222;
$this->remoteUser = null;
$this->remotePassword = null;
$this->backupPath = null;
$this->backupFilePath = null;
$this->importFiles = true;
$this->importDatabases = true;
$this->importEmails = true;
$this->importSsl = true;
}
protected function getAgent(): AgentClient
{
return $this->agent ??= new AgentClient;
}
protected function getImport(): ?ServerImport
{
if (! $this->importId) {
return null;
}
return ServerImport::with('accounts')->find($this->importId);
}
protected function upsertImportForDiscovery(): ServerImport
{
$name = trim((string) ($this->name ?: ''));
if ($name === '') {
$name = 'DirectAdmin Import '.now()->format('Y-m-d H:i');
}
$attributes = [
'name' => $name,
'source_type' => 'directadmin',
'import_method' => $this->importMethod,
'import_options' => [
'files' => $this->importFiles,
'databases' => $this->importDatabases,
'emails' => $this->importEmails,
'ssl' => $this->importSsl,
],
'status' => 'discovering',
'progress' => 0,
'current_task' => null,
];
if ($this->importMethod === 'backup_file') {
$backupPath = filled($this->backupFilePath)
? trim((string) $this->backupFilePath)
: $this->backupPath;
$attributes['backup_path'] = $backupPath ?: null;
$attributes['remote_host'] = null;
$attributes['remote_port'] = null;
$attributes['remote_user'] = null;
} else {
$attributes['backup_path'] = null;
$attributes['remote_host'] = $this->remoteHost ? trim($this->remoteHost) : null;
$attributes['remote_port'] = $this->remotePort;
$attributes['remote_user'] = $this->remoteUser ? trim($this->remoteUser) : null;
if (filled($this->remotePassword)) {
$attributes['remote_password'] = $this->remotePassword;
}
}
$import = $this->importId ? ServerImport::find($this->importId) : null;
if ($import) {
$import->update($attributes);
} else {
$import = ServerImport::create($attributes);
$this->importId = $import->id;
}
$this->saveToSession();
return $import->fresh();
}
protected function getDiscoveredAccountsCount(): int
{
$import = $this->getImport();
return $import ? $import->accounts()->count() : 0;
}
protected function getSelectedAccountsCount(): int
{
$import = $this->getImport();
$selected = $import?->selected_accounts ?? [];
return is_array($selected) ? count($selected) : 0;
}
protected function getAccountsStepDescription(): string
{
$selected = $this->getSelectedAccountsCount();
$total = $this->getDiscoveredAccountsCount();
if ($total === 0) {
return __('No accounts discovered yet.');
}
if ($selected === 0) {
return __(':count accounts discovered', ['count' => $total]);
}
return __(':selected of :count accounts selected', ['selected' => $selected, 'count' => $total]);
}
protected function saveToSession(): void
{
if ($this->importId) {
session()->put('directadmin_migration.import_id', $this->importId);
}
session()->save();
}
protected function restoreFromSession(): void
{
$this->importId = session('directadmin_migration.import_id');
}
protected function restoreFromImport(): void
{
$import = $this->getImport();
if (! $import) {
return;
}
$this->name = $import->name;
$this->importMethod = (string) ($import->import_method ?? 'remote_server');
$backupPath = is_string($import->backup_path) ? trim($import->backup_path) : null;
if ($backupPath && str_starts_with($backupPath, '/')) {
$this->backupFilePath = $backupPath;
$this->backupPath = null;
} else {
$this->backupPath = $backupPath;
$this->backupFilePath = null;
}
$this->remoteHost = $import->remote_host;
$this->remotePort = (int) ($import->remote_port ?? 2222);
$this->remoteUser = $import->remote_user;
$options = $import->import_options ?? [];
if (is_array($options)) {
$this->importFiles = (bool) ($options['files'] ?? true);
$this->importDatabases = (bool) ($options['databases'] ?? true);
$this->importEmails = (bool) ($options['emails'] ?? true);
$this->importSsl = (bool) ($options['ssl'] ?? true);
}
$this->step1Complete = $import->accounts()->exists();
}
}

View File

@@ -41,19 +41,19 @@ class Migration extends Page implements HasForms
public function getSubheading(): ?string
{
return __('Migrate cPanel accounts directly or via WHM');
return __('Migrate cPanel, WHM, or DirectAdmin accounts into Jabali');
}
public function mount(): void
{
if (! in_array($this->activeTab, ['cpanel', 'whm'], true)) {
if (! in_array($this->activeTab, ['cpanel', 'whm', 'directadmin'], true)) {
$this->activeTab = 'cpanel';
}
}
public function updatedActiveTab(string $activeTab): void
{
if (! in_array($activeTab, ['cpanel', 'whm'], true)) {
if (! in_array($activeTab, ['cpanel', 'whm', 'directadmin'], true)) {
$this->activeTab = 'cpanel';
}
}
@@ -79,6 +79,11 @@ class Migration extends Page implements HasForms
->schema([
View::make('filament.admin.pages.migration-whm-tab'),
]),
'directadmin' => Tabs\Tab::make(__('DirectAdmin Migration'))
->icon('heroicon-o-arrow-down-tray')
->schema([
View::make('filament.admin.pages.migration-directadmin-tab'),
]),
]),
]);
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets;
use App\Models\ServerImport;
use App\Models\ServerImportAccount;
use App\Models\User;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Support\Contracts\TranslatableContentDriver;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Attributes\On;
use Livewire\Component;
class DirectAdminAccountConfigTable extends Component implements HasActions, HasSchemas, HasTable
{
use InteractsWithActions;
use InteractsWithSchemas;
use InteractsWithTable;
public ?int $importId = null;
public function mount(?int $importId = null): void
{
$this->importId = $importId ?: session('directadmin_migration.import_id');
}
#[On('directadmin-config-updated')]
#[On('directadmin-selection-updated')]
public function refreshConfig(): void
{
$this->resetTable();
}
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
{
return null;
}
protected function getImport(): ?ServerImport
{
if (! $this->importId) {
return null;
}
return ServerImport::find($this->importId);
}
/**
* @return array<int>
*/
protected function getSelectedAccountIds(): array
{
$selected = $this->getImport()?->selected_accounts ?? [];
return array_values(array_filter(array_map('intval', is_array($selected) ? $selected : [])));
}
/**
* @return \Illuminate\Support\Collection<int, ServerImportAccount>
*/
protected function getRecords()
{
if (! $this->importId) {
return collect();
}
$ids = $this->getSelectedAccountIds();
if ($ids === []) {
return collect();
}
return ServerImportAccount::query()
->where('server_import_id', $this->importId)
->whereIn('id', $ids)
->orderBy('source_username')
->get();
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->getRecords())
->columns([
IconColumn::make('target_user_exists')
->label(__('User'))
->boolean()
->trueIcon('heroicon-o-exclamation-triangle')
->falseIcon('heroicon-o-user-plus')
->trueColor('warning')
->falseColor('success')
->tooltip(fn (ServerImportAccount $record): string => User::where('username', $record->target_username)->exists()
? __('User exists - migration will restore into the existing account')
: __('New user will be created'))
->getStateUsing(fn (ServerImportAccount $record): bool => User::where('username', $record->target_username)->exists()),
TextColumn::make('source_username')
->label(__('Source'))
->weight('bold'),
TextColumn::make('main_domain')
->label(__('Main Domain'))
->wrap(),
TextInputColumn::make('target_username')
->label(__('Target Username'))
->rules([
'required',
'max:32',
'regex:/^[a-z0-9_]+$/i',
]),
TextInputColumn::make('email')
->label(__('Email'))
->rules([
'nullable',
'email',
'max:255',
]),
TextColumn::make('formatted_disk_usage')
->label(__('Disk'))
->toggleable(),
])
->striped()
->paginated([10, 25, 50])
->defaultPaginationPageOption(10)
->emptyStateHeading(__('No accounts selected'))
->emptyStateDescription(__('Go back and select accounts to migrate.'))
->emptyStateIcon('heroicon-o-user-group');
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets;
use App\Models\ServerImport;
use App\Models\ServerImportAccount;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Support\Contracts\TranslatableContentDriver;
use Filament\Support\Enums\IconSize;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Attributes\On;
use Livewire\Component;
class DirectAdminAccountsTable extends Component implements HasActions, HasSchemas, HasTable
{
use InteractsWithActions;
use InteractsWithSchemas;
use InteractsWithTable;
public ?int $importId = null;
public function mount(?int $importId = null): void
{
$this->importId = $importId ?: session('directadmin_migration.import_id');
}
#[On('directadmin-accounts-updated')]
#[On('directadmin-selection-updated')]
public function refreshAccounts(): void
{
$this->resetTable();
}
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
{
return null;
}
protected function getImport(): ?ServerImport
{
if (! $this->importId) {
return null;
}
return ServerImport::find($this->importId);
}
/**
* @return array<int>
*/
protected function getSelectedAccountIds(): array
{
$selected = $this->getImport()?->selected_accounts ?? [];
return array_values(array_filter(array_map('intval', is_array($selected) ? $selected : [])));
}
/**
* @return \Illuminate\Support\Collection<int, ServerImportAccount>
*/
protected function getRecords()
{
if (! $this->importId) {
return collect();
}
return ServerImportAccount::query()
->where('server_import_id', $this->importId)
->orderBy('source_username')
->get();
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->getRecords())
->columns([
IconColumn::make('is_selected')
->label('')
->boolean()
->trueIcon('heroicon-s-check-circle')
->falseIcon('heroicon-o-minus-circle')
->trueColor('primary')
->falseColor('gray')
->size(IconSize::Medium)
->getStateUsing(fn (ServerImportAccount $record): bool => in_array($record->id, $this->getSelectedAccountIds(), true)),
TextColumn::make('source_username')
->label(__('Username'))
->weight('bold')
->searchable(),
TextColumn::make('main_domain')
->label(__('Main Domain'))
->wrap()
->searchable(),
TextColumn::make('email')
->label(__('Email'))
->icon('heroicon-o-envelope')
->toggleable()
->wrap(),
TextColumn::make('formatted_disk_usage')
->label(__('Disk'))
->toggleable(),
])
->recordAction('toggleSelection')
->actions([
Action::make('toggleSelection')
->label(fn (ServerImportAccount $record): string => in_array($record->id, $this->getSelectedAccountIds(), true) ? __('Deselect') : __('Select'))
->icon(fn (ServerImportAccount $record): string => in_array($record->id, $this->getSelectedAccountIds(), true) ? 'heroicon-o-x-mark' : 'heroicon-o-check')
->color(fn (ServerImportAccount $record): string => in_array($record->id, $this->getSelectedAccountIds(), true) ? 'gray' : 'primary')
->action(function (ServerImportAccount $record): void {
$import = $this->getImport();
if (! $import) {
return;
}
$selected = $this->getSelectedAccountIds();
if (in_array($record->id, $selected, true)) {
$selected = array_values(array_diff($selected, [$record->id]));
} else {
$selected[] = $record->id;
$selected = array_values(array_unique($selected));
}
$import->update(['selected_accounts' => $selected]);
$this->dispatch('directadmin-selection-updated');
$this->resetTable();
}),
])
->striped()
->paginated([10, 25, 50])
->defaultPaginationPageOption(25)
->emptyStateHeading(__('No accounts found'))
->emptyStateDescription(__('Discover accounts to see them here.'))
->emptyStateIcon('heroicon-o-user-group')
->poll(null);
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets;
use App\Models\ServerImport;
use App\Models\ServerImportAccount;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Support\Contracts\TranslatableContentDriver;
use Filament\Support\Enums\FontWeight;
use Filament\Support\Enums\IconSize;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Attributes\On;
use Livewire\Component;
class DirectAdminMigrationStatusTable extends Component implements HasActions, HasSchemas, HasTable
{
use InteractsWithActions;
use InteractsWithSchemas;
use InteractsWithTable;
public ?int $importId = null;
public function mount(?int $importId = null): void
{
$this->importId = $importId ?: session('directadmin_migration.import_id');
}
#[On('directadmin-selection-updated')]
public function refreshStatus(): void
{
$this->resetTable();
}
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
{
return null;
}
protected function getImport(): ?ServerImport
{
if (! $this->importId) {
return null;
}
return ServerImport::find($this->importId);
}
/**
* @return array<int>
*/
protected function getSelectedAccountIds(): array
{
$selected = $this->getImport()?->selected_accounts ?? [];
return array_values(array_filter(array_map('intval', is_array($selected) ? $selected : [])));
}
/**
* @return \Illuminate\Support\Collection<int, ServerImportAccount>
*/
protected function getRecords()
{
if (! $this->importId) {
return collect();
}
$ids = $this->getSelectedAccountIds();
if ($ids === []) {
return collect();
}
return ServerImportAccount::query()
->where('server_import_id', $this->importId)
->whereIn('id', $ids)
->orderBy('source_username')
->get();
}
protected function shouldPoll(): bool
{
$import = $this->getImport();
if (! $import) {
return false;
}
if (in_array($import->status, ['discovering', 'importing'], true)) {
return true;
}
foreach ($this->getRecords() as $record) {
if (! in_array($record->status, ['completed', 'failed', 'skipped'], true)) {
return true;
}
}
return false;
}
protected function getStatusText(string $status): string
{
return match ($status) {
'pending' => __('Waiting...'),
'importing' => __('Importing...'),
'completed' => __('Completed'),
'failed' => __('Failed'),
'skipped' => __('Skipped'),
default => __('Unknown'),
};
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->getRecords())
->columns([
IconColumn::make('status_icon')
->label('')
->icon(fn (ServerImportAccount $record): string => match ($record->status) {
'pending' => 'heroicon-o-clock',
'importing' => 'heroicon-o-arrow-path',
'completed' => 'heroicon-o-check-circle',
'failed' => 'heroicon-o-x-circle',
'skipped' => 'heroicon-o-minus-circle',
default => 'heroicon-o-question-mark-circle',
})
->color(fn (ServerImportAccount $record): string => match ($record->status) {
'pending' => 'gray',
'importing' => 'warning',
'completed' => 'success',
'failed' => 'danger',
'skipped' => 'gray',
default => 'gray',
})
->size(IconSize::Small)
->extraAttributes(fn (ServerImportAccount $record): array => $record->status === 'importing'
? ['class' => 'animate-spin']
: []),
TextColumn::make('source_username')
->label(__('Account'))
->weight(FontWeight::Bold)
->searchable(),
TextColumn::make('status')
->label(__('Status'))
->badge()
->formatStateUsing(fn (string $state): string => $this->getStatusText($state))
->color(fn (ServerImportAccount $record): string => match ($record->status) {
'pending' => 'gray',
'importing' => 'warning',
'completed' => 'success',
'failed' => 'danger',
'skipped' => 'gray',
default => 'gray',
}),
TextColumn::make('current_task')
->label(__('Current Task'))
->wrap()
->limit(80)
->default(__('Waiting...')),
TextColumn::make('progress')
->label(__('Progress'))
->suffix('%')
->toggleable(),
])
->striped()
->paginated(false)
->poll($this->shouldPoll() ? '3s' : null)
->emptyStateHeading(__('No selected accounts'))
->emptyStateDescription(__('Select accounts and start migration.'))
->emptyStateIcon('heroicon-o-queue-list');
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -46,6 +46,8 @@ class CpanelMigration extends Page implements HasActions, HasForms
protected static ?string $navigationLabel = null;
protected static bool $shouldRegisterNavigation = false;
public static function getNavigationLabel(): string
{
return __('cPanel Migration');

View File

@@ -0,0 +1,955 @@
<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Models\ServerImport;
use App\Models\ServerImportAccount;
use App\Services\Agent\AgentClient;
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\FileUpload;
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\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\Text;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\View;
use Filament\Schemas\Components\Wizard;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Url;
class DirectAdminMigration extends Page implements HasActions, HasForms
{
use InteractsWithActions;
use InteractsWithForms;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-down-tray';
protected static ?string $navigationLabel = null;
protected static bool $shouldRegisterNavigation = false;
protected static ?int $navigationSort = 16;
protected static ?string $slug = 'directadmin-migration';
protected string $view = 'filament.jabali.pages.directadmin-migration';
#[Url(as: 'directadmin-step')]
public ?string $wizardStep = null;
public bool $step1Complete = false;
public ?int $importId = null;
public string $importMethod = 'backup_file'; // remote_server|backup_file
public ?string $remoteHost = null;
public int $remotePort = 2222;
public ?string $remoteUser = null;
public ?string $remotePassword = null;
public ?string $localBackupPath = null;
public array $availableBackups = [];
public ?string $backupPath = null;
public bool $importFiles = true;
public bool $importDatabases = true;
public bool $importEmails = true;
public bool $importSsl = true;
protected ?AgentClient $agent = null;
public static function getNavigationLabel(): string
{
return __('DirectAdmin Migration');
}
public function getTitle(): string|Htmlable
{
return __('DirectAdmin Migration');
}
public function getSubheading(): ?string
{
return __('Migrate your DirectAdmin account into your Jabali account');
}
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 the DirectAdmin migration wizard. Are you sure?'))
->action('resetMigration'),
];
}
public function mount(): void
{
$this->restoreFromSession();
$this->restoreFromImport();
if ($this->importMethod === 'backup_file') {
$this->loadLocalBackups();
}
}
public function updatedImportMethod(): void
{
$this->remoteHost = null;
$this->remotePort = 2222;
$this->remoteUser = null;
$this->remotePassword = null;
$this->localBackupPath = null;
$this->backupPath = null;
$this->availableBackups = [];
if ($this->importMethod === 'backup_file') {
$this->loadLocalBackups();
}
}
public function updatedLocalBackupPath(): void
{
if (! $this->localBackupPath) {
$this->backupPath = null;
return;
}
$this->selectLocalBackup();
}
protected function getForms(): array
{
return ['migrationForm'];
}
public function migrationForm(Schema $schema): Schema
{
return $schema->schema([
Wizard::make([
$this->getConnectStep(),
$this->getConfigureStep(),
$this->getMigrateStep(),
])
->persistStepInQueryString('directadmin-step'),
]);
}
protected function getConnectStep(): Step
{
return Step::make(__('Connect'))
->id('connect')
->icon('heroicon-o-link')
->description(__('Connect to DirectAdmin or upload a backup'))
->schema([
Section::make(__('Source'))
->description(__('For now, migration requires a DirectAdmin backup archive. Remote migration will be added next.'))
->icon('heroicon-o-server')
->schema([
Radio::make('importMethod')
->label(__('Import Method'))
->options([
'backup_file' => __('Backup File'),
'remote_server' => __('Remote Server (Discovery only)'),
])
->default('backup_file')
->live(),
Grid::make(['default' => 1, 'sm' => 2])
->schema([
TextInput::make('remoteHost')
->label(__('Host'))
->placeholder('directadmin.example.com')
->required()
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
TextInput::make('remotePort')
->label(__('Port'))
->numeric()
->default(2222)
->required()
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
TextInput::make('remoteUser')
->label(__('Username'))
->required()
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
TextInput::make('remotePassword')
->label(__('Password'))
->password()
->revealable()
->required()
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
]),
Section::make(__('Backup File'))
->description(__('Upload your DirectAdmin backup archive to your backups folder, then select it here.'))
->icon('heroicon-o-folder')
->visible(fn (Get $get): bool => $get('importMethod') === 'backup_file')
->headerActions([
Action::make('uploadBackup')
->label(__('Upload'))
->icon('heroicon-o-arrow-up-tray')
->color('gray')
->modalHeading(__('Upload Backup'))
->modalDescription(fn (): string => ($user = $this->getUser())
? __('Upload a DirectAdmin backup archive into /home/:user/backups', ['user' => $user->username])
: __('Upload a DirectAdmin backup archive into your backups folder'))
->modalSubmitActionLabel(__('Upload'))
->form([
FileUpload::make('backup')
->label(__('DirectAdmin Backup Archive'))
->storeFiles(false)
->required()
->maxSize(512000) // 500MB in KB
->helperText(__('Supported formats: .tar.zst, .tar.gz, .tgz (max 500MB via upload)')),
])
->action(function (array $data): void {
try {
$user = $this->getUser();
if (! $user) {
throw new Exception(__('You must be logged in.'));
}
$file = $data['backup'] ?? null;
if (! $file) {
throw new Exception(__('Please select a backup file.'));
}
$filename = (string) $file->getClientOriginalName();
$filename = basename($filename);
if (! preg_match('/\\.(tar\\.zst|zst|tar\\.gz|tgz)$/i', $filename)) {
throw new Exception(__('Backup must be a .zst, .tar.zst, .tar.gz or .tgz file.'));
}
$maxBytes = 500 * 1024 * 1024;
$fileSize = (int) ($file->getSize() ?? 0);
if ($fileSize > $maxBytes) {
throw new Exception(__('File too large for upload (max 500MB). Upload it via SSH/SFTP to /home/:user/backups.', [
'user' => $user->username,
]));
}
// Ensure backups folder exists (mkdir will error if it already exists).
try {
$this->getAgent()->fileMkdir($user->username, 'backups');
} catch (Exception $e) {
if ($e->getMessage() !== 'Path already exists') {
throw $e;
}
}
// Stage into the agent-allowed temp dir, then let the agent move it.
$tmpDir = '/tmp/jabali-uploads';
if (! is_dir($tmpDir)) {
mkdir($tmpDir, 0700, true);
chmod($tmpDir, 0700);
} else {
@chmod($tmpDir, 0700);
}
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
$tmpPath = $tmpDir.'/'.uniqid('upload_', true).'_'.$safeName;
if (! @copy($file->getRealPath(), $tmpPath)) {
throw new Exception(__('Failed to stage upload.'));
}
@chmod($tmpPath, 0600);
$result = $this->getAgent()->send('file.upload_temp', [
'username' => $user->username,
'path' => 'backups',
'filename' => $safeName,
'temp_path' => $tmpPath,
]);
if (! ($result['success'] ?? false)) {
if (file_exists($tmpPath)) {
@unlink($tmpPath);
}
throw new Exception((string) ($result['error'] ?? __('Upload failed')));
}
$this->loadLocalBackups();
$uploadedPath = $result['path'] ?? null;
if (is_string($uploadedPath) && $uploadedPath !== '') {
$this->localBackupPath = $uploadedPath;
$this->selectLocalBackup();
}
Notification::make()
->title(__('Backup uploaded'))
->body(__('Uploaded :name', ['name' => $safeName]))
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(__('Upload failed'))
->body($e->getMessage())
->danger()
->send();
}
}),
Action::make('refreshLocalBackups')
->label(__('Refresh'))
->icon('heroicon-o-arrow-path')
->color('gray')
->action('refreshLocalBackups'),
])
->schema([
Select::make('localBackupPath')
->label(__('Backup File'))
->options(fn (): array => $this->getLocalBackupOptions())
->searchable()
->required(fn (Get $get): bool => $get('importMethod') === 'backup_file')
->live(),
Text::make(fn (): string => $this->backupPath
? __('Selected file: :file', ['file' => basename($this->backupPath)])
: __('No backup selected yet.'))
->color('gray'),
Text::make(fn (): string => ($user = $this->getUser())
? __('Upload the file to: /home/:user/backups', ['user' => $user->username])
: __('Upload the file to your /home/<user>/backups folder.'))
->color('gray'),
Text::make(__('Supported formats: .tar.zst, .tar.gz, .tgz'))->color('gray'),
Text::make(fn (): string => ($user = $this->getUser())
? __('No backups found in /home/:user/backups. Upload a file there and click Refresh.', ['user' => $user->username])
: __('No backups found.'))
->color('gray')
->visible(fn (): bool => empty($this->availableBackups)),
]),
FormActions::make([
Action::make('discoverAccount')
->label(__('Discover Account'))
->icon('heroicon-o-magnifying-glass')
->color('primary')
->action('discoverAccount'),
])->alignEnd(),
]),
Section::make(__('Discovery'))
->description(__('After discovery, you can choose what to import.'))
->icon('heroicon-o-user')
->schema([
Text::make(__('Discovered account details will be used for migration.'))->color('gray'),
]),
])
->afterValidation(function () {
$import = $this->getImport();
$hasAccounts = $import?->accounts()->exists() ?? false;
if (! $hasAccounts) {
Notification::make()
->title(__('No account discovered'))
->body(__('Click "Discover Account" to continue.'))
->danger()
->send();
throw new Exception(__('No account discovered'));
}
$this->step1Complete = true;
$this->saveToSession();
});
}
protected function getConfigureStep(): Step
{
return Step::make(__('Configure'))
->id('configure')
->icon('heroicon-o-cog')
->description(__('Choose what to import'))
->schema([
Section::make(__('What to Import'))
->description(__('Select which parts of your account to import.'))
->icon('heroicon-o-check-circle')
->schema([
Grid::make(['default' => 1, 'sm' => 2])->schema([
Checkbox::make('importFiles')
->label(__('Website Files'))
->helperText(__('Restore website files from the backup'))
->default(true),
Checkbox::make('importDatabases')
->label(__('Databases'))
->helperText(__('Restore MySQL databases and import dumps'))
->default(true),
Checkbox::make('importEmails')
->label(__('Email'))
->helperText(__('Create email domains and mailboxes (limited in Phase 1)'))
->default(true),
Checkbox::make('importSsl')
->label(__('SSL'))
->helperText(__('Install custom certificates or issue Let\'s Encrypt (Phase 3)'))
->default(true),
]),
]),
])
->afterValidation(function (): void {
$import = $this->getImport();
if (! $import) {
throw new Exception(__('Import job not found'));
}
$import->update([
'import_options' => [
'files' => $this->importFiles,
'databases' => $this->importDatabases,
'emails' => $this->importEmails,
'ssl' => $this->importSsl,
],
]);
$this->saveToSession();
});
}
protected function getMigrateStep(): Step
{
return Step::make(__('Migrate'))
->id('migrate')
->icon('heroicon-o-play')
->description(__('Run the migration and watch progress'))
->schema([
FormActions::make([
Action::make('startMigration')
->label(__('Start Migration'))
->icon('heroicon-o-play')
->color('success')
->requiresConfirmation()
->modalHeading(__('Start Migration'))
->modalDescription(__('This will import data into your Jabali account. Continue?'))
->action('startMigration'),
Action::make('newMigration')
->label(__('New Migration'))
->icon('heroicon-o-plus')
->color('primary')
->visible(fn (): bool => ($this->getImport()?->status ?? null) === 'completed')
->action('resetMigration'),
])->alignEnd(),
Section::make(__('Import Status'))
->icon('heroicon-o-queue-list')
->schema([
View::make('filament.jabali.pages.directadmin-migration-status-table'),
]),
]);
}
public function discoverAccount(): void
{
try {
$user = Auth::user();
if (! $user) {
throw new Exception(__('You must be logged in.'));
}
$import = $this->upsertImportForDiscovery();
$backupFullPath = null;
$remotePassword = null;
if ($this->importMethod === 'backup_file') {
if (! $import->backup_path) {
throw new Exception(__('Please select a DirectAdmin backup archive.'));
}
$backupFullPath = $this->resolveBackupFullPath($import->backup_path);
if (! $backupFullPath) {
throw new Exception(__('Backup file not found: :path', ['path' => $import->backup_path]));
}
} else {
$remotePassword = $this->remotePassword;
if (($remotePassword === null || $remotePassword === '') && filled($import->remote_password)) {
$remotePassword = (string) $import->remote_password;
}
if (! $import->remote_host || ! $import->remote_port || ! $import->remote_user || ! $remotePassword) {
throw new Exception(__('Please enter DirectAdmin host, port, username and password.'));
}
}
$result = $this->getAgent()->importDiscover(
$import->id,
'directadmin',
$import->import_method,
$backupFullPath,
$import->remote_host,
$import->remote_port ? (int) $import->remote_port : null,
$import->remote_user,
$remotePassword,
);
if (! ($result['success'] ?? false)) {
throw new Exception((string) ($result['error'] ?? __('Discovery failed')));
}
$accounts = $result['accounts'] ?? [];
if (! is_array($accounts) || $accounts === []) {
throw new Exception(__('No account was discovered.'));
}
$account = null;
if (count($accounts) === 1) {
$account = $accounts[0];
} else {
// Prefer matching the provided username if multiple accounts are returned.
foreach ($accounts as $candidate) {
if (! is_array($candidate)) {
continue;
}
if (($candidate['username'] ?? null) === $this->remoteUser) {
$account = $candidate;
break;
}
}
}
if (! is_array($account)) {
throw new Exception(__('Multiple accounts were discovered. Please upload a single-user backup archive.'));
}
$sourceUsername = trim((string) ($account['username'] ?? ''));
if ($sourceUsername === '') {
throw new Exception(__('Discovered account is missing a username.'));
}
$import->accounts()->delete();
$record = ServerImportAccount::create([
'server_import_id' => $import->id,
'source_username' => $sourceUsername,
'target_username' => $user->username,
'email' => (string) ($account['email'] ?? ''),
'main_domain' => (string) ($account['main_domain'] ?? ''),
'addon_domains' => $account['addon_domains'] ?? [],
'subdomains' => $account['subdomains'] ?? [],
'databases' => $account['databases'] ?? [],
'email_accounts' => $account['email_accounts'] ?? [],
'disk_usage' => (int) ($account['disk_usage'] ?? 0),
'status' => 'pending',
'progress' => 0,
'current_task' => null,
'import_log' => [],
'error' => null,
]);
$import->update([
'discovered_accounts' => [$account],
'selected_accounts' => [$record->id],
'status' => 'ready',
'progress' => 0,
'current_task' => null,
'errors' => [],
]);
$this->importId = $import->id;
$this->step1Complete = true;
$this->saveToSession();
$this->dispatch('directadmin-self-status-updated');
Notification::make()
->title(__('Account discovered'))
->body(__('Ready to migrate into your Jabali account (:username).', ['username' => $user->username]))
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(__('Discovery failed'))
->body($e->getMessage())
->danger()
->send();
}
}
public function startMigration(): void
{
$import = $this->getImport();
if (! $import) {
Notification::make()
->title(__('Import job not found'))
->danger()
->send();
return;
}
$selected = $import->selected_accounts ?? [];
if (! is_array($selected) || $selected === []) {
Notification::make()
->title(__('No account selected'))
->danger()
->send();
return;
}
if ($import->import_method === 'remote_server') {
Notification::make()
->title(__('Remote DirectAdmin import is not available yet'))
->body(__('For now, please download a DirectAdmin backup archive and use the "Backup File" method.'))
->warning()
->send();
return;
}
$import->update([
'status' => 'importing',
'started_at' => now(),
]);
$result = $this->getAgent()->importStart($import->id);
if (! ($result['success'] ?? false)) {
Notification::make()
->title(__('Failed to start migration'))
->body((string) ($result['error'] ?? __('Unknown error')))
->danger()
->send();
return;
}
Notification::make()
->title(__('Migration started'))
->body(__('Import process has started in the background.'))
->success()
->send();
$this->dispatch('directadmin-self-status-updated');
}
public function resetMigration(): void
{
if ($this->importId) {
ServerImport::whereKey($this->importId)->delete();
}
session()->forget('directadmin_self_migration.import_id');
$this->wizardStep = null;
$this->step1Complete = false;
$this->importId = null;
$this->importMethod = 'backup_file';
$this->remoteHost = null;
$this->remotePort = 2222;
$this->remoteUser = null;
$this->remotePassword = null;
$this->localBackupPath = null;
$this->availableBackups = [];
$this->backupPath = null;
$this->importFiles = true;
$this->importDatabases = true;
$this->importEmails = true;
$this->importSsl = true;
}
protected function getAgent(): AgentClient
{
return $this->agent ??= new AgentClient;
}
protected function getUser()
{
return Auth::user();
}
protected function loadLocalBackups(): void
{
$this->availableBackups = [];
$user = $this->getUser();
if (! $user) {
return;
}
$result = $this->getAgent()->send('file.list', [
'username' => $user->username,
'path' => 'backups',
]);
if (! ($result['success'] ?? false)) {
$this->getAgent()->send('file.mkdir', [
'username' => $user->username,
'path' => 'backups',
]);
$result = $this->getAgent()->send('file.list', [
'username' => $user->username,
'path' => 'backups',
]);
if (! ($result['success'] ?? false)) {
return;
}
}
$items = $result['items'] ?? [];
foreach ($items as $item) {
if (($item['is_dir'] ?? false) === true) {
continue;
}
$name = (string) ($item['name'] ?? '');
if (! preg_match('/\\.(tar\\.zst|zst|tar\\.gz|tgz)$/i', $name)) {
continue;
}
$this->availableBackups[] = $item;
}
}
public function refreshLocalBackups(): void
{
$this->loadLocalBackups();
Notification::make()
->title(__('Backup list refreshed'))
->success()
->send();
}
protected function getLocalBackupOptions(): array
{
$options = [];
foreach ($this->availableBackups as $item) {
$path = $item['path'] ?? null;
$name = $item['name'] ?? null;
if (! $path || ! $name) {
continue;
}
$size = $this->formatBytes((int) ($item['size'] ?? 0));
$options[$path] = "{$name} ({$size})";
}
return $options;
}
protected function selectLocalBackup(): void
{
$user = $this->getUser();
if (! $user || ! $this->localBackupPath) {
return;
}
$info = $this->getAgent()->send('file.info', [
'username' => $user->username,
'path' => $this->localBackupPath,
]);
if (! ($info['success'] ?? false)) {
Notification::make()
->title(__('Backup file not found'))
->body($info['error'] ?? __('Unable to read backup file'))
->danger()
->send();
$this->backupPath = null;
return;
}
$details = $info['info'] ?? [];
if (! ($details['is_file'] ?? false)) {
Notification::make()
->title(__('Invalid backup selection'))
->body(__('Please select a backup file'))
->warning()
->send();
$this->backupPath = null;
return;
}
$this->backupPath = "/home/{$user->username}/{$this->localBackupPath}";
Notification::make()
->title(__('Backup selected'))
->body(__('Selected :name (:size)', [
'name' => $details['name'] ?? basename($this->backupPath),
'size' => $this->formatBytes((int) ($details['size'] ?? 0)),
]))
->success()
->send();
}
protected function formatBytes(int $bytes): string
{
if ($bytes >= 1073741824) {
return number_format($bytes / 1073741824, 2).' GB';
}
if ($bytes >= 1048576) {
return number_format($bytes / 1048576, 2).' MB';
}
if ($bytes >= 1024) {
return number_format($bytes / 1024, 2).' KB';
}
return $bytes.' B';
}
protected function resolveBackupFullPath(?string $path): ?string
{
$path = trim((string) ($path ?? ''));
if ($path === '') {
return null;
}
if (str_starts_with($path, '/') && file_exists($path)) {
return $path;
}
$localCandidate = Storage::disk('local')->path($path);
if (file_exists($localCandidate)) {
return $localCandidate;
}
$backupCandidate = Storage::disk('backups')->path($path);
if (file_exists($backupCandidate)) {
return $backupCandidate;
}
return file_exists($path) ? $path : null;
}
protected function getImport(): ?ServerImport
{
if (! $this->importId) {
return null;
}
return ServerImport::with('accounts')->find($this->importId);
}
protected function upsertImportForDiscovery(): ServerImport
{
$user = Auth::user();
$name = $user ? ('DirectAdmin Import - '.$user->username.' - '.now()->format('Y-m-d H:i')) : ('DirectAdmin Import '.now()->format('Y-m-d H:i'));
$attributes = [
'name' => $name,
'source_type' => 'directadmin',
'import_method' => $this->importMethod,
'import_options' => [
'files' => $this->importFiles,
'databases' => $this->importDatabases,
'emails' => $this->importEmails,
'ssl' => $this->importSsl,
],
'status' => 'discovering',
'progress' => 0,
'current_task' => null,
];
if ($this->importMethod === 'backup_file') {
$attributes['backup_path'] = $this->backupPath;
$attributes['remote_host'] = null;
$attributes['remote_port'] = null;
$attributes['remote_user'] = null;
} else {
$attributes['backup_path'] = null;
$attributes['remote_host'] = $this->remoteHost ? trim($this->remoteHost) : null;
$attributes['remote_port'] = $this->remotePort;
$attributes['remote_user'] = $this->remoteUser ? trim($this->remoteUser) : null;
if (filled($this->remotePassword)) {
$attributes['remote_password'] = $this->remotePassword;
}
}
$import = $this->importId ? ServerImport::find($this->importId) : null;
if ($import) {
$import->update($attributes);
} else {
$import = ServerImport::create($attributes);
$this->importId = $import->id;
}
$this->saveToSession();
return $import->fresh();
}
protected function saveToSession(): void
{
if ($this->importId) {
session()->put('directadmin_self_migration.import_id', $this->importId);
}
session()->save();
}
protected function restoreFromSession(): void
{
$this->importId = session('directadmin_self_migration.import_id');
}
protected function restoreFromImport(): void
{
$import = $this->getImport();
if (! $import) {
return;
}
$this->importMethod = (string) ($import->import_method ?? 'backup_file');
$this->backupPath = $import->backup_path;
if ($this->backupPath && ($user = $this->getUser())) {
$prefix = "/home/{$user->username}/";
if (str_starts_with($this->backupPath, $prefix)) {
$this->localBackupPath = ltrim(substr($this->backupPath, strlen($prefix)), '/');
}
}
$this->remoteHost = $import->remote_host;
$this->remotePort = (int) ($import->remote_port ?? 2222);
$this->remoteUser = $import->remote_user;
$options = $import->import_options ?? [];
if (is_array($options)) {
$this->importFiles = (bool) ($options['files'] ?? true);
$this->importDatabases = (bool) ($options['databases'] ?? true);
$this->importEmails = (bool) ($options['emails'] ?? true);
$this->importSsl = (bool) ($options['ssl'] ?? true);
}
$this->step1Complete = $import->accounts()->exists();
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use BackedEnum;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable;
use Livewire\Attributes\Url;
class Migration extends Page implements HasForms
{
use InteractsWithForms;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-down-tray';
protected static ?string $navigationLabel = null;
protected static ?int $navigationSort = 15;
protected string $view = 'filament.jabali.pages.migration';
#[Url(as: 'migration')]
public string $activeTab = 'cpanel';
public static function getNavigationLabel(): string
{
return __('Migration');
}
public function getTitle(): string|Htmlable
{
return __('Migration');
}
public function getSubheading(): ?string
{
return __('Migrate a cPanel or DirectAdmin account into your Jabali account');
}
public function mount(): void
{
if (! in_array($this->activeTab, ['cpanel', 'directadmin'], true)) {
$this->activeTab = 'cpanel';
}
}
public function updatedActiveTab(string $activeTab): void
{
if (! in_array($activeTab, ['cpanel', 'directadmin'], true)) {
$this->activeTab = 'cpanel';
}
}
protected function getForms(): array
{
return ['migrationForm'];
}
public function migrationForm(Schema $schema): Schema
{
return $schema->schema([
Tabs::make(__('Migration Type'))
->livewireProperty('activeTab')
->tabs([
'cpanel' => Tabs\Tab::make(__('cPanel Migration'))
->icon('heroicon-o-arrow-down-tray')
->schema([
View::make('filament.jabali.pages.migration-cpanel-tab'),
]),
'directadmin' => Tabs\Tab::make(__('DirectAdmin Migration'))
->icon('heroicon-o-arrow-down-tray')
->schema([
View::make('filament.jabali.pages.migration-directadmin-tab'),
]),
]),
]);
}
}

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Widgets;
use App\Models\ServerImport;
use App\Models\ServerImportAccount;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Support\Contracts\TranslatableContentDriver;
use Filament\Support\Enums\FontWeight;
use Filament\Support\Enums\IconSize;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Attributes\On;
use Livewire\Component;
class DirectAdminMigrationStatusTable extends Component implements HasActions, HasSchemas, HasTable
{
use InteractsWithActions;
use InteractsWithSchemas;
use InteractsWithTable;
public ?int $importId = null;
public function mount(?int $importId = null): void
{
$this->importId = $importId ?: session('directadmin_self_migration.import_id');
}
#[On('directadmin-self-status-updated')]
public function refreshStatus(): void
{
$this->resetTable();
}
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
{
return null;
}
protected function getImport(): ?ServerImport
{
if (! $this->importId) {
return null;
}
return ServerImport::find($this->importId);
}
/**
* @return \Illuminate\Support\Collection<int, ServerImportAccount>
*/
protected function getRecords()
{
if (! $this->importId) {
return collect();
}
return ServerImportAccount::query()
->where('server_import_id', $this->importId)
->orderBy('source_username')
->get();
}
protected function shouldPoll(): bool
{
$import = $this->getImport();
if (! $import) {
return false;
}
if (in_array($import->status, ['discovering', 'importing'], true)) {
return true;
}
foreach ($this->getRecords() as $record) {
if (! in_array($record->status, ['completed', 'failed', 'skipped'], true)) {
return true;
}
}
return false;
}
protected function getStatusText(string $status): string
{
return match ($status) {
'pending' => __('Waiting...'),
'importing' => __('Importing...'),
'completed' => __('Completed'),
'failed' => __('Failed'),
'skipped' => __('Skipped'),
default => __('Unknown'),
};
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->getRecords())
->columns([
IconColumn::make('status_icon')
->label('')
->icon(fn (ServerImportAccount $record): string => match ($record->status) {
'pending' => 'heroicon-o-clock',
'importing' => 'heroicon-o-arrow-path',
'completed' => 'heroicon-o-check-circle',
'failed' => 'heroicon-o-x-circle',
'skipped' => 'heroicon-o-minus-circle',
default => 'heroicon-o-question-mark-circle',
})
->color(fn (ServerImportAccount $record): string => match ($record->status) {
'pending' => 'gray',
'importing' => 'warning',
'completed' => 'success',
'failed' => 'danger',
'skipped' => 'gray',
default => 'gray',
})
->size(IconSize::Small)
->extraAttributes(fn (ServerImportAccount $record): array => $record->status === 'importing'
? ['class' => 'animate-spin']
: []),
TextColumn::make('source_username')
->label(__('Account'))
->weight(FontWeight::Bold)
->searchable(),
TextColumn::make('status')
->label(__('Status'))
->badge()
->formatStateUsing(fn (string $state): string => $this->getStatusText($state))
->color(fn (ServerImportAccount $record): string => match ($record->status) {
'pending' => 'gray',
'importing' => 'warning',
'completed' => 'success',
'failed' => 'danger',
'skipped' => 'gray',
default => 'gray',
}),
TextColumn::make('current_task')
->label(__('Current Task'))
->wrap()
->limit(80)
->default(__('Waiting...')),
TextColumn::make('progress')
->label(__('Progress'))
->suffix('%')
->toggleable(),
])
->striped()
->paginated(false)
->poll($this->shouldPoll() ? '3s' : null)
->emptyStateHeading(__('No migration activity'))
->emptyStateDescription(__('Discover an account and start migration.'))
->emptyStateIcon('heroicon-o-queue-list');
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -185,5 +185,53 @@ class BackupSchedule extends Model
return $timezone;
}
/**
* Scope for active schedules.
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Scope for due schedules.
*/
public function scopeDue($query)
{
return $query->active()
->where(function ($q) {
$q->whereNull('next_run_at')
->orWhere('next_run_at', '<=', now());
});
}
/**
* Scope for user schedules.
*/
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
/**
* Scope for server backup schedules.
*/
public function scopeServerBackups($query)
{
return $query->where('is_server_backup', true);
}
/**
* Get last status color for UI.
*/
public function getLastStatusColorAttribute(): string
{
return match ($this->last_status) {
'success' => 'success',
'failed' => 'danger',
default => 'gray',
};
}
}

View File

@@ -92,7 +92,7 @@ class User extends Authenticatable implements FilamentUser
]);
}
}
} catch (\Exception $e) {
} catch (\Throwable $e) {
\Log::warning("Failed to delete email forwarders for user {$user->username}: ".$e->getMessage());
}
@@ -100,32 +100,37 @@ class User extends Authenticatable implements FilamentUser
$masterUser = $user->username.'_admin';
try {
// Use credentials from environment variables
$mysqli = new \mysqli(
config('database.connections.mysql.host', 'localhost'),
config('database.connections.mysql.username'),
config('database.connections.mysql.password')
);
if (class_exists(\mysqli::class)) {
// Use credentials from environment variables
$mysqli = new \mysqli(
config('database.connections.mysql.host', 'localhost'),
config('database.connections.mysql.username'),
config('database.connections.mysql.password')
);
if (! $mysqli->connect_error) {
// Use prepared statement to prevent SQL injection
// MySQL doesn't support prepared statements for DROP USER,
// so we validate the username format strictly
if (! preg_match('/^[a-zA-Z0-9_]+$/', $masterUser)) {
throw new \Exception('Invalid MySQL username format');
if (! $mysqli->connect_error) {
// Use prepared statement to prevent SQL injection
// MySQL doesn't support prepared statements for DROP USER,
// so we validate the username format strictly
if (! preg_match('/^[a-zA-Z0-9_]+$/', $masterUser)) {
throw new \Exception('Invalid MySQL username format');
}
// Escape the username as an additional safety measure
$escapedUser = $mysqli->real_escape_string($masterUser);
$mysqli->query("DROP USER IF EXISTS '{$escapedUser}'@'localhost'");
$mysqli->close();
}
// Escape the username as an additional safety measure
$escapedUser = $mysqli->real_escape_string($masterUser);
$mysqli->query("DROP USER IF EXISTS '{$escapedUser}'@'localhost'");
$mysqli->close();
}
// Delete stored credentials
\App\Models\MysqlCredential::where('user_id', $user->id)->delete();
} catch (\Exception $e) {
} catch (\Throwable $e) {
\Log::error('Failed to delete master MySQL user: '.$e->getMessage());
}
try {
\App\Models\MysqlCredential::where('user_id', $user->id)->delete();
} catch (\Throwable $e) {
\Log::error('Failed to delete stored MySQL credentials: '.$e->getMessage());
}
});
}

View File

@@ -12681,23 +12681,30 @@ function discoverDirectAdminBackup(string $backupPath, string $extractDir): arra
$accounts = [];
$tarArgs = '';
if (preg_match('/\\.(tar\\.zst|zst)$/i', $backupPath)) {
$tarArgs = '--zstd';
} elseif (preg_match('/\\.(tar\\.gz|tgz)$/i', $backupPath)) {
$tarArgs = '-I pigz';
}
// List archive contents
$cmd = "tar -I pigz -tf " . escapeshellarg($backupPath) . " 2>/dev/null | head -500";
$cmd = "tar $tarArgs -tf " . escapeshellarg($backupPath) . " 2>/dev/null | head -500";
exec($cmd, $fileList, $code);
if ($code !== 0) {
throw new Exception('Failed to read backup file. Make sure it is a valid tar.gz archive.');
throw new Exception('Failed to read backup file. Make sure it is a valid DirectAdmin archive (.tar.zst, .tar.gz, .tgz).');
}
$fileListStr = implode("\n", $fileList);
// DirectAdmin backup structure: backup/user.conf, domains/, databases/
// Or: user.username.tar.gz containing the above
// Or: user.username.tar.zst (or .tar.gz) containing the above
// Check for user.conf file (single user backup)
if (preg_match('/(backup\/)?user\.conf/', $fileListStr)) {
// Extract user.conf and domains list
$extractCmd = "tar -I pigz -xf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . " --wildcards 'backup/user.conf' 'user.conf' 'domains/*' 'backup/domains/*' 2>/dev/null";
$extractCmd = "tar $tarArgs -xf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . " --wildcards 'backup/user.conf' 'user.conf' 'domains/*' 'backup/domains/*' 2>/dev/null";
exec($extractCmd);
$userConf = null;
@@ -12718,18 +12725,25 @@ function discoverDirectAdminBackup(string $backupPath, string $extractDir): arra
// Check for multiple user backups (full server backup)
foreach ($fileList as $file) {
if (preg_match('/user\.([a-z0-9_]+)\.tar\.gz/i', $file, $matches)) {
if (preg_match('/user\\.([a-z0-9_]+)\\.(?:tar\\.zst|zst|tar\\.gz|tgz)$/i', $file, $matches)) {
$username = $matches[1];
// Extract just this user's backup
$innerBackup = $file;
exec("tar -I pigz -xf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . " " . escapeshellarg($innerBackup) . " 2>/dev/null");
exec("tar $tarArgs -xf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . " " . escapeshellarg($innerBackup) . " 2>/dev/null");
$innerPath = "$extractDir/$innerBackup";
if (file_exists($innerPath)) {
$innerTarArgs = '';
if (preg_match('/\\.(tar\\.zst|zst)$/i', $innerPath)) {
$innerTarArgs = '--zstd';
} elseif (preg_match('/\\.(tar\\.gz|tgz)$/i', $innerPath)) {
$innerTarArgs = '-I pigz';
}
$innerExtract = "$extractDir/$username";
mkdir($innerExtract, 0755, true);
exec("tar -I pigz -xf " . escapeshellarg($innerPath) . " -C " . escapeshellarg($innerExtract) . " --wildcards 'backup/user.conf' 'user.conf' 2>/dev/null");
exec("tar $innerTarArgs -xf " . escapeshellarg($innerPath) . " -C " . escapeshellarg($innerExtract) . " --wildcards 'backup/user.conf' 'user.conf' 2>/dev/null");
$userConf = glob("$innerExtract/*/user.conf")[0] ?? glob("$innerExtract/user.conf")[0] ?? null;
if ($userConf) {
@@ -12901,6 +12915,43 @@ function discoverDirectAdminRemote(string $host, int $port, string $user, string
$url = "https://$host:$port/CMD_API_SHOW_ALL_USERS";
$fetchUserConfig = function (string $username) use ($host, $port, $user, $password): ?array {
$detailUrl = "https://$host:$port/CMD_API_SHOW_USER_CONFIG?user=" . urlencode($username);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $detailUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_USERPWD, "$user:$password");
$userResponse = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error || $httpCode !== 200 || !is_string($userResponse) || $userResponse === '') {
return null;
}
parse_str($userResponse, $userData);
if (isset($userData['error']) && $userData['error'] === '1') {
return null;
}
return [
'username' => $username,
'email' => $userData['email'] ?? '',
'main_domain' => $userData['domain'] ?? '',
'addon_domains' => [],
'subdomains' => [],
'databases' => [],
'email_accounts' => [],
'disk_usage' => ($userData['bandwidth'] ?? 0) * 1048576,
];
};
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
@@ -12926,6 +12977,13 @@ function discoverDirectAdminRemote(string $host, int $port, string $user, string
parse_str($response, $data);
if (isset($data['error']) && $data['error'] === '1') {
// Regular DirectAdmin users are not allowed to call CMD_API_SHOW_ALL_USERS.
// In that case, fall back to discovering a single account using the same credentials.
$single = $fetchUserConfig($user);
if ($single) {
return ['success' => true, 'accounts' => [$single]];
}
return ['success' => false, 'error' => $data['text'] ?? 'Unknown error'];
}
@@ -12937,35 +12995,22 @@ function discoverDirectAdminRemote(string $host, int $port, string $user, string
$userList = [$userList];
}
if (empty($userList)) {
$single = $fetchUserConfig($user);
if ($single) {
return ['success' => true, 'accounts' => [$single]];
}
return ['success' => false, 'error' => 'No users returned by DirectAdmin'];
}
foreach ($userList as $username) {
if (empty($username)) continue;
// Get user details
$detailUrl = "https://$host:$port/CMD_API_SHOW_USER_CONFIG?user=" . urlencode($username);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $detailUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_USERPWD, "$user:$password");
$userResponse = curl_exec($ch);
curl_close($ch);
parse_str($userResponse, $userData);
$accounts[] = [
'username' => $username,
'email' => $userData['email'] ?? '',
'main_domain' => $userData['domain'] ?? '',
'addon_domains' => [],
'subdomains' => [],
'databases' => [],
'email_accounts' => [],
'disk_usage' => ($userData['bandwidth'] ?? 0) * 1048576,
];
$account = $fetchUserConfig($username);
if ($account) {
$accounts[] = $account;
}
}
return ['success' => true, 'accounts' => $accounts];
@@ -12976,7 +13021,7 @@ function discoverDirectAdminRemote(string $host, int $port, string $user, string
*/
function importStart(array $params): array
{
$importId = $params['import_id'] ?? 0;
$importId = (int) ($params['import_id'] ?? 0);
logger("Starting import process for import ID: $importId");
@@ -12990,7 +13035,7 @@ function importStart(array $params): array
}
// Dispatch a Laravel job to handle the import in the background
$cmd = "cd /var/www/jabali && php artisan import:process " . escapeshellarg($importId) . " > /dev/null 2>&1 &";
$cmd = "cd /var/www/jabali && php artisan import:process " . escapeshellarg((string) $importId) . " > /dev/null 2>&1 &";
exec($cmd);
return ['success' => true, 'message' => 'Import process started'];

View File

@@ -37,6 +37,13 @@ return [
'root' => '/tmp',
'throw' => false,
],
// Server-wide backups folder (created by install.sh)
'backups' => [
'driver' => 'local',
'root' => env('JABALI_BACKUPS_ROOT', '/var/backups/jabali'),
'throw' => false,
],
],
'links' => [

View File

@@ -0,0 +1,277 @@
# DirectAdmin Migration Blueprint
This blueprint describes how Jabali Panel should migrate accounts from a remote
DirectAdmin server into Jabali.
It is written to match Jabali's current architecture:
- Laravel 12 + Filament v5 + Livewire v4 UI.
- Privileged agent (`bin/jabali-agent`) for root-level operations.
- Long-running work via jobs/queue with resumable logs.
## Goals
- Support connecting to a remote DirectAdmin server (host, port, credentials).
- Support multi-account migrations (admin-initiated).
- Support user self-migration (user-initiated, scoped to their Jabali account).
- Migrate websites, databases, email, and SSL.
- Provide clear progress, per-account logs, and safe retries.
## Non-Goals (Initial Scope)
- Reseller plans and quota mapping (can be added later).
- DNS zone migrations from DirectAdmin (optional later).
- Password migration for website logins and mailboxes (not possible in general).
## UX Overview
Jabali already has:
- Admin migration entry: `jabali-admin/migration` (tabs page).
- User migration entry: `jabali-panel/cpanel-migration` (cPanel only today).
DirectAdmin migration should be added to both panels:
- Admin: new migration tab alongside cPanel and WHM.
- User: new self-migration page similar to user cPanel migration.
The UI should use Filament native components (Wizard, Sections, Tables), and
should not embed custom HTML/CSS.
## Admin Flow (Multi-Account)
### Step 1: Connect
Inputs:
- Hostname or IP
- Port (default DirectAdmin: 2222)
- Auth:
- Username + password (initial)
- Optional future: API token
- SSL verify toggle (default on, allow off for lab servers)
Actions:
- Test connection
- Discover users/accounts
Output:
- Server metadata (DirectAdmin version if available)
- Discovered accounts summary
### Step 2: Select Accounts
Show a table of discovered accounts:
- Source username
- Main domain
- Email contact
- Disk usage (if provided)
Selection:
- Multi-select accounts for import.
Per-account mapping:
- Target Jabali username (editable, default = source username)
- Target user email (editable)
- Conflict indicators (existing Jabali user, existing domains)
### Step 3: Choose What To Import
Toggles:
- Files
- Databases
- Email
- SSL
Optional safety toggles:
- Skip existing domains
- Skip existing databases
- Re-issue SSL via Let's Encrypt when custom SSL is missing or invalid
### Step 4: Run Migration
Execution runs as a background job batch:
- Per-account status: pending, running, completed, failed, skipped.
- Per-account logs (timestamps + messages).
- Global log for the import job.
Controls:
- Cancel import (best-effort stop at safe boundaries).
- Retry failed accounts.
## User Flow (Self-Migration)
User self-migration is a guided flow to import a single DirectAdmin account into
the currently authenticated Jabali user.
### Step 1: Connect
Inputs:
- DirectAdmin hostname/IP and port
- DirectAdmin username + password
Actions:
- Test connection
- Verify the DirectAdmin user is accessible
### Step 2: Choose What To Import
Toggles:
- Files
- Databases
- Email
- SSL
### Step 3: Run Migration
Show:
- Live progress
- Logs
- Final summary
Scope and enforcement:
- Target Jabali user is fixed to the authenticated user.
- Import must refuse to touch domains that do not belong to the user.
## Data Model (Proposed)
Jabali already has:
- `server_imports` and `server_import_accounts`.
To support DirectAdmin remote migration properly, add:
- `server_imports.created_by_user_id` (nullable, for admin-created vs user-created).
- `server_imports.target_user_id` (nullable, for admin selecting a target user, optional).
- `server_import_accounts.backup_path` (nullable, per-account backup archive path when remote).
- `server_import_accounts.ssl_items` (json, optional, discovered SSL material per domain).
- `server_import_accounts.mail_items` (json, optional, discovered mailboxes and domains).
Also update `server_imports.import_options` to include:
- `files`, `databases`, `emails`, `ssl`.
## Import Pipeline (High-Level)
### Phase A: Discovery
Purpose:
- Validate credentials and enumerate accounts to import.
Implementation notes:
- Jabali agent already supports discovery for DirectAdmin remote via:
- `CMD_API_SHOW_ALL_USERS`
- `CMD_API_SHOW_USER_CONFIG`
### Phase B: Backup Creation and Download (Remote Method)
Problem:
- The current import processor only imports from local backup archives.
Solution:
- For `import_method=remote_server`, create and download a DirectAdmin backup
per selected account to `storage/app/private/imports/...`.
Implementation choices:
- Run this phase in a queued job to avoid request timeouts.
- Download must stream to disk, not to memory.
- Store paths per account (`server_import_accounts.backup_path`).
### Phase C: Analyze Backup (Optional But Recommended)
Purpose:
- Extract account metadata (domains, DB dumps, email list, SSL presence).
- Show a preview before the destructive restore phase.
Implementation notes:
- Reuse the existing agent discovery for backup files (`import.discover`).
- Extend discovery to detect:
- Mailbox domains and mailbox names
- SSL certificate files per domain (if present)
### Phase D: Restore Into Jabali
For each account:
1. Create or map Jabali user.
2. Create domains.
3. Restore website files into the correct document roots.
4. Restore databases and import dumps.
5. Restore email domains and mailboxes, then copy Maildir data.
6. Restore SSL certificates (or issue Let's Encrypt if configured).
All steps must write logs to both:
- `server_imports.import_log`
- `server_import_accounts.import_log`
## Email Migration (Requirements)
Minimum requirements:
- Create mail domains in Jabali for the migrated domains.
- Create mailboxes for the discovered mailbox usernames.
- Import Maildir content (messages, folders) into the new mailboxes.
Recommended approach:
- Use Jabali agent functions:
- `email.enable_domain`
- `email.mailbox_create`
- `email.sync_maps` and `email.reload_services` when needed
Notes:
- DirectAdmin backups can include hashed mailbox passwords, which are not
directly reusable for Dovecot. Use new random passwords and provide a
"reset passwords" output list to the admin or user.
## SSL Migration (Requirements)
Minimum requirements:
- If custom SSL material is present in the DirectAdmin backup, install it for
each domain in Jabali.
Recommended approach:
- Extract PEM certificate, private key, and optional chain from the backup.
- Use Jabali agent function `ssl.install` per domain.
Fallback:
- If SSL material is missing or invalid, allow issuing Let's Encrypt via
`ssl.issue` after DNS and vhost are ready.
## Multi-Account Support
Admin migration must allow selecting multiple DirectAdmin users and migrating
them in one batch.
Execution model:
- One `server_imports` record per batch.
- One `server_import_accounts` record per DirectAdmin user.
- Independent status and retry per account.
## Security and Compliance
- Store remote passwords and tokens encrypted at rest (already supported for
`server_imports.remote_password` and `server_imports.remote_api_token`).
- Never write raw credentials into logs.
- Provide a "forget credentials" action after the migration completes.
- Rate-limit connection tests and discovery to reduce abuse.
## Observability
- Show per-account progress and current step in the UI.
- Write a compact global log and a detailed per-account log.
- On failures, capture enough context to troubleshoot:
- Which step failed
- The relevant domain or database name
- A short error string without secrets
## Implementation Phases (Suggested)
Phase 1:
- Admin: add DirectAdmin migration tab (UI skeleton).
- User: add DirectAdmin self-migration page (UI skeleton).
- Wire UI to the existing `server_imports` discovery (remote and backup-file).
Phase 2:
- Implement remote backup creation and download.
- Store per-account backup paths.
- Make `import:process` support `remote_server` by using downloaded archives.
Phase 3:
- Implement email restore (mail domains, mailboxes, Maildir copy).
- Implement SSL restore (custom cert install, LE fallback).
Phase 4:
- Add tests (discovery, permissions, per-account import state).
- Add docs and screenshots for the new pages/tabs.

View File

@@ -1,6 +1,6 @@
# Jabali Documentation Index
Last updated: 2026-02-09
Last updated: 2026-02-10
## Top-Level Docs
- /var/www/jabali/README.md - Product overview, features, install, upgrade, and architecture summary.
@@ -13,6 +13,7 @@ Last updated: 2026-02-09
## Docs Folder
- /var/www/jabali/docs/installation.md - Debian package install path, Filament notifications patch, and deploy script usage.
- /var/www/jabali/docs/architecture/control-panel-blueprint.md - High-level blueprint for a hosting panel.
- /var/www/jabali/docs/architecture/directadmin-migration-blueprint.md - Blueprint for migrating DirectAdmin accounts into Jabali.
- /var/www/jabali/docs/archive-notes.md - Archived files and restore notes.
- /var/www/jabali/docs/screenshots/README.md - Screenshot generation instructions.
- /var/www/jabali/docs/docs-summary.md - Project documentation summary (generated).

View File

@@ -1,6 +1,6 @@
# Documentation Summary (Jabali Panel)
Last updated: 2026-02-09
Last updated: 2026-02-10
## Product Overview
Jabali Panel is a modern web hosting control panel for WordPress and general PHP hosting. It provides an admin panel for server-wide operations and a user panel for per-tenant management. The core goals are safe automation, clean multi-tenant isolation, and operational clarity.
@@ -53,5 +53,6 @@ mcp-docs-server exposes README, AGENT docs, and changelog through MCP tools for
## Miscellaneous Docs
- Screenshot regeneration script: tests/take-screenshots.cjs.
- DirectAdmin migration blueprint: docs/architecture/directadmin-migration-blueprint.md.
- Policies: resources/markdown/policy.md and resources/markdown/terms.md are placeholders.
- WordPress plugin: resources/wordpress/jabali-cache/readme.txt documents the Jabali Cache plugin.

View File

@@ -16,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -f "$SCRIPT_DIR/VERSION" ]]; then
JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")"
fi
JABALI_VERSION="${JABALI_VERSION:-0.9-rc60}"
JABALI_VERSION="${JABALI_VERSION:-0.9-rc61}"
# Colors
RED='\033[0;31m'
@@ -2945,8 +2945,9 @@ EOF
mkdir -p /var/backups/jabali
mkdir -p /var/backups/jabali/cpanel-migrations
mkdir -p /var/backups/jabali/whm-migrations
mkdir -p /var/backups/jabali/directadmin-migrations
chown -R $JABALI_USER:$JABALI_USER /var/backups/jabali
chmod 755 /var/backups/jabali /var/backups/jabali/cpanel-migrations /var/backups/jabali/whm-migrations
chmod 755 /var/backups/jabali /var/backups/jabali/cpanel-migrations /var/backups/jabali/whm-migrations /var/backups/jabali/directadmin-migrations
log "Jabali Panel setup complete"
}

View File

@@ -0,0 +1,4 @@
@livewire(\App\Filament\Admin\Widgets\DirectAdminAccountConfigTable::class, [
'importId' => $this->importId,
], key('directadmin-account-config-table-' . ($this->importId ?? 'new')))

View File

@@ -0,0 +1,4 @@
@livewire(\App\Filament\Admin\Widgets\DirectAdminAccountsTable::class, [
'importId' => $this->importId,
], key('directadmin-accounts-table-' . ($this->importId ?? 'new')))

View File

@@ -0,0 +1,4 @@
@livewire(\App\Filament\Admin\Widgets\DirectAdminMigrationStatusTable::class, [
'importId' => $this->importId,
], key('directadmin-migration-status-table-' . ($this->importId ?? 'new')))

View File

@@ -0,0 +1,6 @@
<x-filament-panels::page>
{{ $this->migrationForm }}
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -0,0 +1,2 @@
@livewire(\App\Filament\Admin\Pages\DirectAdminMigration::class, [], key('migration-directadmin'))

View File

@@ -0,0 +1,4 @@
@livewire(\App\Filament\Jabali\Widgets\DirectAdminMigrationStatusTable::class, [
'importId' => $this->importId,
], key('directadmin-self-migration-status-table-' . ($this->importId ?? 'new')))

View File

@@ -0,0 +1,6 @@
<x-filament-panels::page>
{{ $this->migrationForm }}
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -0,0 +1,2 @@
@livewire(\App\Filament\Jabali\Pages\CpanelMigration::class, [], key('migration-cpanel'))

View File

@@ -0,0 +1,2 @@
@livewire(\App\Filament\Jabali\Pages\DirectAdminMigration::class, [], key('migration-directadmin'))

View File

@@ -0,0 +1,4 @@
<x-filament-panels::page>
{{ $this->migrationForm }}
</x-filament-panels::page>

View File

@@ -1,33 +1,53 @@
@php
$jabaliVersion = '1.0.1';
$jabaliVersion = 'unknown';
$versionFile = base_path('VERSION');
if (file_exists($versionFile)) {
$content = file_get_contents($versionFile);
if (preg_match('/VERSION=(.+)/', $content, $matches)) {
if (preg_match('/^VERSION=(.+)$/m', $content, $matches)) {
$jabaliVersion = trim($matches[1]);
}
}
@endphp
<style>
.dark .jabali-footer-logo { filter: invert(1) brightness(2); }
</style>
<div style="border-top: 1px solid rgba(128,128,128,0.1); padding: 20px 24px; margin-top: auto;">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px;">
<div style="display: flex; align-items: center; gap: 12px;">
<img src="{{ asset('images/jabali_logo.svg') }}" alt="Jabali" style="height: 32px; width: 32px;" class="jabali-footer-logo">
<div>
<div style="font-weight: 600; font-size: 15px;" class="text-gray-700 dark:text-gray-200">Jabali Panel</div>
<div style="font-size: 12px;" class="text-gray-500 dark:text-gray-400">Web Hosting Control Panel</div>
<footer class="mt-auto border-t border-gray-200/20 px-6 py-5 dark:border-white/10">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="flex items-center gap-3">
<img
src="{{ asset('images/jabali_logo.svg') }}"
alt="{{ __('Jabali') }}"
class="h-8 w-8 dark:filter dark:invert dark:brightness-200"
>
<div class="leading-tight">
<div class="text-sm font-semibold text-gray-700 dark:text-gray-200">
{{ __('Jabali Panel') }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ __('Web Hosting Control Panel') }}
</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 16px; font-size: 13px;" class="text-gray-500 dark:text-gray-400">
<a href="https://github.com/shukiv/jabali-panel" target="_blank" style="display: flex; align-items: center; gap: 6px; text-decoration: none;" class="text-gray-500 dark:text-gray-400 hover:text-blue-500">
<svg style="width: 16px; height: 16px;" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
GitHub
</a>
<span style="color: rgba(128,128,128,0.3);"></span>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500 dark:text-gray-400">
<x-filament::link
tag="a"
href="https://github.com/shukiv/jabali-panel"
target="_blank"
rel="noopener"
color="gray"
size="sm"
>
{{ __('GitHub') }}
</x-filament::link>
<span class="text-gray-400/50 dark:text-white/20"></span>
<span>© {{ date('Y') }} Jabali</span>
<span style="background: linear-gradient(135deg, #3b82f6, #8b5cf6); color: white; padding: 3px 10px; border-radius: 4px; font-size: 11px; font-weight: 600;">v{{ $jabaliVersion }}</span>
<x-filament::badge size="sm" color="gray">
v{{ $jabaliVersion }}
</x-filament::badge>
</div>
</div>
</div>
</footer>

View File

@@ -27,7 +27,7 @@ class ServerChartsWidgetTest extends TestCase
$this->assertArrayHasKey('disk', $history);
$this->assertArrayHasKey('/', $history['disk']);
$this->assertArrayHasKey('/boot', $history['disk']);
$this->assertCount(5, $history['labels']);
$this->assertCount(30, $history['labels']);
$this->assertCount(count($history['labels']), $history['swap']);
$this->assertCount(count($history['labels']), $history['iowait']);
}