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

1265 lines
53 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Models\Backup;
use App\Models\BackupDestination;
use App\Models\BackupRestore;
use App\Models\Domain;
use App\Models\EmailDomain;
use App\Models\Mailbox;
use App\Models\UserRemoteBackup;
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\CheckboxList;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
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\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
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 Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
use Livewire\Attributes\Url;
class Backups extends Page implements HasActions, HasForms, HasTable
{
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
protected static ?string $navigationLabel = null;
public static function getNavigationLabel(): string
{
return __('Backups');
}
protected static ?int $navigationSort = 14;
protected string $view = 'filament.jabali.pages.backups';
#[Url(as: 'tab')]
public ?string $activeTab = 'local';
public ?int $selectedBackupId = null;
protected ?AgentClient $agent = null;
public function getTitle(): string|Htmlable
{
return __('Backups');
}
public function getAgent(): AgentClient
{
return $this->agent ??= new AgentClient;
}
protected function getUser()
{
return Auth::user();
}
public function mount(): void
{
$this->activeTab = $this->normalizeTabName($this->activeTab);
}
protected function normalizeTabName(?string $tab): string
{
return match ($tab) {
'local', 'remote', 'destinations', 'history' => $tab,
default => 'local',
};
}
public function setTab(string $tab): void
{
$this->activeTab = $this->normalizeTabName($tab);
$this->resetTable();
}
protected function getForms(): array
{
return ['backupsForm'];
}
public function backupsForm(Schema $schema): Schema
{
return $schema->schema([
Section::make(__('Backup Management'))
->description(__('Create and manage backups of your account data. Backups include your websites, databases, and mailboxes. You can restore from any backup at any time.'))
->icon('heroicon-o-information-circle')
->iconColor('info'),
Tabs::make(__('Backup Sections'))
->contained()
->livewireProperty('activeTab')
->tabs([
'local' => Tab::make(__('My Backups'))
->icon('heroicon-o-archive-box')
->schema([
View::make('filament.jabali.pages.backups-tab-table'),
]),
'remote' => Tab::make(__('Server Backups'))
->icon('heroicon-o-cloud')
->schema([
View::make('filament.jabali.pages.backups-tab-table'),
]),
'destinations' => Tab::make(__('SFTP Destinations'))
->icon('heroicon-o-server-stack')
->schema([
View::make('filament.jabali.pages.backups-tab-table'),
]),
'history' => Tab::make(__('Restore History'))
->icon('heroicon-o-arrow-path')
->schema([
View::make('filament.jabali.pages.backups-tab-table'),
]),
]),
]);
}
public function table(Table $table): Table
{
return match ($this->activeTab) {
'local' => $this->localBackupsTable($table),
'remote' => $this->remoteBackupsTable($table),
'destinations' => $this->destinationsTable($table),
'history' => $this->restoreHistoryTable($table),
default => $this->localBackupsTable($table),
};
}
protected function localBackupsTable(Table $table): Table
{
return $table
->query(Backup::query()->where('user_id', $this->getUser()->id))
->columns([
TextColumn::make('name')
->label(__('Name'))
->searchable()
->sortable()
->description(fn (Backup $record) => $record->created_at->format('M j, Y H:i')),
TextColumn::make('status')
->label(__('Status'))
->badge()
->color(fn (string $state): string => match ($state) {
'completed' => 'success',
'running' => 'warning',
'pending' => 'gray',
'failed' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => ucfirst($state)),
TextColumn::make('size_human')
->label(__('Size'))
->sortable(query: fn (Builder $query, string $direction) => $query->orderBy('size_bytes', $direction)),
IconColumn::make('include_files')
->label(__('Files'))
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('gray'),
IconColumn::make('include_databases')
->label(__('DB'))
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('gray'),
IconColumn::make('include_mailboxes')
->label(__('Mail'))
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('gray'),
TextColumn::make('created_at')
->label(__('Created'))
->dateTime('M j, Y H:i')
->sortable(),
])
->defaultSort('created_at', 'desc')
->recordActions([
Action::make('download')
->label(__('Download'))
->icon('heroicon-o-arrow-down-tray')
->color('gray')
->size('sm')
->url(fn (Backup $record) => route('filament.jabali.pages.backup-download', ['path' => base64_encode($record->local_path ?? '')]))
->openUrlInNewTab()
->visible(fn (Backup $record) => $record->canDownload()),
Action::make('restore')
->label(__('Restore'))
->icon('heroicon-o-arrow-path')
->color('warning')
->size('sm')
->modalHeading(__('Restore Backup'))
->modalDescription(__('Select what you want to restore from this backup. Existing data may be overwritten.'))
->modalIcon('heroicon-o-arrow-path')
->modalIconColor('warning')
->modalSubmitActionLabel(__('Start Restore'))
->form([
Section::make(__('Restore Options'))
->description(__('Choose which types of data to restore'))
->schema([
Grid::make(2)->schema([
Toggle::make('restore_files')
->label(__('Restore Website Files'))
->default(true)
->helperText(__('Restores all website files to their original locations')),
Toggle::make('restore_databases')
->label(__('Restore Databases'))
->default(true)
->helperText(__('Restores MySQL databases (overwrites existing data)')),
Toggle::make('restore_mailboxes')
->label(__('Restore Mailboxes'))
->default(true)
->helperText(__('Restores email accounts and messages')),
]),
]),
])
->action(function (array $data, Backup $record): void {
$this->selectedBackupId = $record->id;
$this->performRestore($data);
})
->visible(fn (Backup $record) => $record->status === 'completed'),
Action::make('delete')
->label(__('Delete'))
->icon('heroicon-o-trash')
->color('danger')
->size('sm')
->requiresConfirmation()
->modalHeading(__('Delete Backup'))
->modalDescription(__('Are you sure you want to delete this backup? This action cannot be undone.'))
->action(function (Backup $record): void {
$user = $this->getUser();
if ($record->local_path) {
try {
$this->getAgent()->backupDelete($user->username, $record->local_path);
} catch (Exception) {
// Continue anyway
}
}
$record->delete();
Notification::make()->title(__('Backup deleted'))->success()->send();
}),
])
->emptyStateHeading(__('No backups yet'))
->emptyStateDescription(__('Click "Create Backup" to create your first backup of your account data.'))
->emptyStateIcon('heroicon-o-archive-box')
->striped();
}
protected function remoteBackupsTable(Table $table): Table
{
return $table
->query(
UserRemoteBackup::query()
->where('user_id', $this->getUser()->id)
->with('destination')
->orderByDesc('backup_date')
)
->columns([
TextColumn::make('backup_name')
->label(__('Backup'))
->icon('heroicon-o-cloud')
->iconColor('info')
->description(fn (UserRemoteBackup $record): string => $record->backup_date?->format('M j, Y H:i') ?? '')
->searchable(),
TextColumn::make('backup_type')
->label(__('Type'))
->badge()
->formatStateUsing(fn (string $state): string => $state === 'incremental' ? __('Incremental') : __('Full'))
->color(fn (string $state): string => $state === 'incremental' ? 'info' : 'success'),
TextColumn::make('destination.name')
->label(__('Destination')),
])
->recordActions([
Action::make('restore')
->label(__('Restore'))
->icon('heroicon-o-arrow-path')
->color('warning')
->size('sm')
->modalHeading(__('Restore from Server Backup'))
->modalDescription(__('This will download and restore the backup. Select what you want to restore.'))
->form([
Section::make(__('Restore Options'))
->schema([
Grid::make(2)->schema([
Toggle::make('restore_files')->label(__('Website Files'))->default(true),
Toggle::make('restore_databases')->label(__('Databases'))->default(true),
Toggle::make('restore_mailboxes')->label(__('Mailboxes'))->default(true),
]),
]),
])
->action(fn (array $data, UserRemoteBackup $record) => $this->restoreFromRemoteBackup($record, $data)),
Action::make('download')
->label(__('Download'))
->icon('heroicon-o-arrow-down-tray')
->color('gray')
->size('sm')
->action(fn (UserRemoteBackup $record) => $this->downloadFromRemote($record->destination_id, $record->backup_path)),
])
->headerActions([
Action::make('refresh')
->label(__('Refresh'))
->icon('heroicon-o-arrow-path')
->color('gray')
->action(function () {
// Dispatch re-indexing job
\App\Jobs\IndexRemoteBackups::dispatch();
Notification::make()
->title(__('Refreshing backup list'))
->body(__('The backup list will be updated in a moment.'))
->info()
->send();
}),
])
->emptyStateHeading(__('No server backups found'))
->emptyStateDescription(__('Your backups from scheduled server backups will appear here when available.'))
->emptyStateIcon('heroicon-o-cloud')
->striped();
}
protected function restoreHistoryTable(Table $table): Table
{
return $table
->query(BackupRestore::query()->where('user_id', $this->getUser()->id)->with('backup'))
->columns([
TextColumn::make('backup.name')
->label(__('Backup'))
->placeholder(__('Unknown'))
->searchable(),
TextColumn::make('status')
->label(__('Status'))
->badge()
->formatStateUsing(fn (BackupRestore $record): string => $record->status_label)
->color(fn (BackupRestore $record): string => $record->status_color),
TextColumn::make('progress')
->label(__('Progress'))
->formatStateUsing(fn (int $state): string => $state.'%')
->color(fn (BackupRestore $record): string => $record->status === 'running' ? 'warning' : 'gray'),
TextColumn::make('created_at')
->label(__('Date'))
->dateTime('M j, Y H:i')
->sortable(),
TextColumn::make('duration')
->label(__('Duration'))
->placeholder(__('-')),
])
->defaultSort('created_at', 'desc')
->emptyStateHeading(__('No restore history'))
->emptyStateDescription(__('Your backup restore operations will appear here.'))
->emptyStateIcon('heroicon-o-arrow-path')
->striped();
}
protected function destinationsTable(Table $table): Table
{
return $table
->query(BackupDestination::query()->where('user_id', $this->getUser()->id)->where('is_server_backup', false))
->columns([
TextColumn::make('name')
->label(__('Name'))
->weight('medium')
->searchable(),
TextColumn::make('type')
->label(__('Type'))
->badge()
->formatStateUsing(fn (string $state): string => strtoupper($state))
->color('info'),
TextColumn::make('test_status')
->label(__('Status'))
->badge()
->formatStateUsing(fn (?string $state): string => match ($state) {
'success' => __('Connected'),
'failed' => __('Failed'),
default => __('Not Tested'),
})
->color(fn (?string $state): string => match ($state) {
'success' => 'success',
'failed' => 'danger',
default => 'gray',
}),
TextColumn::make('last_tested_at')
->label(__('Last Tested'))
->since()
->placeholder(__('Never')),
])
->recordActions([
Action::make('test')
->label(__('Test'))
->icon('heroicon-o-check-circle')
->color('success')
->size('sm')
->action(fn (BackupDestination $record) => $this->testUserDestination($record->id)),
Action::make('delete')
->label(__('Delete'))
->icon('heroicon-o-trash')
->color('danger')
->size('sm')
->requiresConfirmation()
->action(fn (BackupDestination $record) => $this->deleteUserDestination($record->id)),
])
->headerActions([
$this->addDestinationAction(),
])
->emptyStateHeading(__('No SFTP destinations configured'))
->emptyStateDescription(__('Add an SFTP server to upload your backups to remote storage.'))
->emptyStateIcon('heroicon-o-server-stack')
->striped();
}
public function restoreFromRemoteBackup(UserRemoteBackup $record, array $data): void
{
$user = $this->getUser();
$destination = $record->destination;
if (! $destination) {
Notification::make()->title(__('Destination not found'))->danger()->send();
return;
}
// Create temp directory for download
$tempPath = sys_get_temp_dir().'/jabali_restore_'.uniqid();
mkdir($tempPath, 0755, true);
Notification::make()
->title(__('Downloading backup'))
->body(__('Please wait while the backup is downloaded...'))
->info()
->send();
try {
$config = array_merge($destination->config ?? [], ['type' => $destination->type]);
$downloadResult = $this->getAgent()->send('backup.download_remote', [
'remote_path' => $record->backup_path,
'local_path' => $tempPath,
'destination' => $config,
]);
if (! ($downloadResult['success'] ?? false)) {
throw new Exception($downloadResult['error'] ?? __('Download failed'));
}
// Now restore from the downloaded backup
$restoreResult = $this->getAgent()->send('backup.restore', [
'username' => $user->username,
'backup_path' => $tempPath,
'restore_files' => $data['restore_files'] ?? false,
'restore_databases' => $data['restore_databases'] ?? false,
'restore_mailboxes' => $data['restore_mailboxes'] ?? false,
]);
// Cleanup temp directory
exec('rm -rf '.escapeshellarg($tempPath));
if ($restoreResult['success'] ?? false) {
$restoredItems = [];
// Create database records for restored mailboxes
$restoredMailboxes = $restoreResult['restored']['mailboxes'] ?? [];
if (! empty($restoredMailboxes)) {
foreach ($restoredMailboxes as $mailboxEmail) {
$this->createMailboxRecord($user, $mailboxEmail);
}
$restoredItems[] = count($restoredMailboxes).' '.__('mailbox(es)');
}
// Count other restored items
if (! empty($restoreResult['restored']['files'] ?? [])) {
$restoredItems[] = count($restoreResult['restored']['files']).' '.__('domain(s)');
}
if (! empty($restoreResult['restored']['databases'] ?? [])) {
$restoredItems[] = count($restoreResult['restored']['databases']).' '.__('database(s)');
}
$message = ! empty($restoredItems) ? implode(', ', $restoredItems) : __('No items needed restoring');
Notification::make()
->title(__('Restore completed'))
->body($message)
->success()
->send();
} else {
throw new Exception($restoreResult['error'] ?? __('Restore failed'));
}
} catch (Exception $e) {
// Cleanup on error
if (is_dir($tempPath)) {
exec('rm -rf '.escapeshellarg($tempPath));
}
Notification::make()
->title(__('Restore failed'))
->body($e->getMessage())
->danger()
->send();
}
}
/**
* Create database record for a restored mailbox.
* Files are restored by the agent, this creates the DB entry so the mailbox appears in the panel.
*/
protected function createMailboxRecord($user, string $mailboxEmail): void
{
// Parse email
$parts = explode('@', $mailboxEmail);
if (count($parts) !== 2) {
return;
}
$localPart = $parts[0];
$domainName = $parts[1];
// Check if mailbox already exists
$existingMailbox = Mailbox::whereHas('emailDomain.domain', function ($query) use ($domainName) {
$query->where('domain', $domainName);
})->where('local_part', $localPart)->first();
if ($existingMailbox) {
return; // Already exists
}
// Find the domain
$domain = Domain::where('domain', $domainName)
->where('user_id', $user->id)
->first();
if (! $domain) {
return; // Domain not found for this user
}
// Find or create email domain
$emailDomain = EmailDomain::firstOrCreate(
['domain_id' => $domain->id],
[
'is_active' => true,
'max_mailboxes' => 10,
'max_quota_bytes' => 10737418240, // 10GB
]
);
// Generate a temporary password
$tempPassword = Str::random(16);
// Get password hash from agent
try {
$result = $this->getAgent()->send('email.hash_password', ['password' => $tempPassword]);
$passwordHash = $result['password_hash'] ?? '';
} catch (\Exception $e) {
// Fallback: generate SHA512-CRYPT hash in PHP
$passwordHash = '{SHA512-CRYPT}'.crypt($tempPassword, '$6$'.bin2hex(random_bytes(8)).'$');
}
// Get system user UID/GID
$userInfo = posix_getpwnam($user->username);
$systemUid = $userInfo['uid'] ?? null;
$systemGid = $userInfo['gid'] ?? null;
// The maildir path in user's home directory
$maildirPath = "/home/{$user->username}/mail/{$domainName}/{$localPart}/";
// Create the mailbox record
Mailbox::create([
'email_domain_id' => $emailDomain->id,
'user_id' => $user->id,
'local_part' => $localPart,
'password_hash' => $passwordHash,
'password_encrypted' => Crypt::encryptString($tempPassword),
'maildir_path' => $maildirPath,
'system_uid' => $systemUid,
'system_gid' => $systemGid,
'name' => $localPart,
'quota_bytes' => 1073741824, // 1GB default
'is_active' => true,
'imap_enabled' => true,
'pop3_enabled' => true,
'smtp_enabled' => true,
]);
}
protected function getHeaderActions(): array
{
return [
Action::make('createBackup')
->label(__('Create Backup'))
->icon('heroicon-o-archive-box-arrow-down')
->color('primary')
->modalHeading(__('Create Backup'))
->modalDescription(__('Create a backup of your account data including websites, databases, and mailboxes.'))
->modalIcon('heroicon-o-archive-box-arrow-down')
->modalIconColor('primary')
->modalSubmitActionLabel(__('Create Backup'))
->form([
TextInput::make('name')
->label(__('Backup Name'))
->default(fn () => __('Backup').' '.now()->format('Y-m-d H:i'))
->required()
->helperText(__('A descriptive name to identify this backup')),
Select::make('destination_id')
->label(__('Save To'))
->options(fn () => BackupDestination::where('user_id', Auth::id())
->where('is_active', true)
->pluck('name', 'id')
->prepend(__('Local (Home Folder)'), ''))
->default('')
->helperText(__('Choose where to store your backup')),
Section::make(__('What to Include'))
->description(__('Select the data you want to include in this backup'))
->schema([
Grid::make(2)->schema([
Toggle::make('include_files')
->label(__('Website Files'))
->default(true)
->helperText(__('All files in your domains folder')),
Toggle::make('include_databases')
->label(__('Databases'))
->default(true)
->helperText(__('All MySQL databases and data')),
Toggle::make('include_mailboxes')
->label(__('Mailboxes'))
->default(true)
->helperText(__('All email accounts and messages')),
Toggle::make('include_ssl')
->label(__('SSL Certificates'))
->default(true)
->helperText(__('SSL certificates for your domains')),
]),
]),
])
->action(function (array $data) {
$this->createBackup($data);
}),
];
}
public function createBackup(array $data): void
{
$user = $this->getUser();
$timestamp = now()->format('Y-m-d_His');
$filename = "backup_{$timestamp}.tar.gz";
$outputPath = "/home/{$user->username}/backups/{$filename}";
$destinationId = ! empty($data['destination_id']) ? (int) $data['destination_id'] : null;
$backup = Backup::create([
'user_id' => $user->id,
'name' => $data['name'],
'filename' => $filename,
'type' => 'full',
'include_files' => $data['include_files'] ?? true,
'include_databases' => $data['include_databases'] ?? true,
'include_mailboxes' => $data['include_mailboxes'] ?? true,
'destination_id' => $destinationId,
'status' => 'pending',
'local_path' => $outputPath,
]);
try {
$backup->update(['status' => 'running', 'started_at' => now()]);
$result = $this->getAgent()->backupCreate($user->username, $outputPath, [
'include_files' => $data['include_files'] ?? true,
'include_databases' => $data['include_databases'] ?? true,
'include_mailboxes' => $data['include_mailboxes'] ?? true,
'include_ssl' => $data['include_ssl'] ?? true,
]);
if ($result['success']) {
$backup->update([
'status' => 'completed',
'completed_at' => now(),
'size_bytes' => $result['size'] ?? 0,
'checksum' => $result['checksum'] ?? null,
'domains' => $result['domains'] ?? null,
'databases' => $result['databases'] ?? null,
'mailboxes' => $result['mailboxes'] ?? null,
]);
// Upload to SFTP if destination selected
if ($destinationId) {
$this->uploadBackupToDestination($backup, $destinationId);
} else {
Notification::make()->title(__('Backup created successfully'))->success()->send();
}
} else {
throw new Exception($result['error'] ?? 'Backup failed');
}
} catch (Exception $e) {
$backup->update([
'status' => 'failed',
'completed_at' => now(),
'error_message' => $e->getMessage(),
]);
Notification::make()->title(__('Backup failed'))->body($e->getMessage())->danger()->send();
}
$this->resetTable();
}
protected function uploadBackupToDestination(Backup $backup, int $destinationId): void
{
$user = $this->getUser();
$destination = BackupDestination::where('id', $destinationId)
->where('user_id', $user->id)
->first();
if (! $destination) {
Notification::make()
->title(__('Backup created locally'))
->body(__('Could not upload to destination - not found'))
->warning()
->send();
return;
}
try {
$backup->update(['status' => 'uploading']);
$config = array_merge($destination->config ?? [], ['type' => $destination->type]);
$result = $this->getAgent()->send('backup.upload_remote', [
'local_path' => $backup->local_path,
'destination' => $config,
'backup_type' => 'full',
]);
if ($result['success'] ?? false) {
$backup->update([
'status' => 'completed',
'remote_path' => $result['remote_path'] ?? null,
]);
Notification::make()
->title(__('Backup created and uploaded'))
->body(__('Backup saved to :destination', ['destination' => $destination->name]))
->success()
->send();
} else {
throw new Exception($result['error'] ?? __('Upload failed'));
}
} catch (Exception $e) {
$backup->update([
'status' => 'completed',
'error_message' => __('Local backup created, but upload failed: ').$e->getMessage(),
]);
Notification::make()
->title(__('Backup created locally'))
->body(__('Upload to :destination failed: :error', [
'destination' => $destination->name,
'error' => $e->getMessage(),
]))
->warning()
->send();
}
}
public ?int $selectedBackupIdForDelete = null;
public ?string $selectedPathForDelete = null;
public function confirmDeleteBackup(int $id): void
{
$this->selectedBackupIdForDelete = $id;
$this->mountAction('deleteBackupAction');
}
public function deleteBackupAction(): Action
{
return Action::make('deleteBackupAction')
->requiresConfirmation()
->modalHeading(__('Delete Backup'))
->modalDescription(__('Are you sure you want to delete this backup? This action cannot be undone.'))
->modalIcon('heroicon-o-trash')
->modalIconColor('danger')
->modalSubmitActionLabel(__('Delete Backup'))
->color('danger')
->action(function (): void {
$user = $this->getUser();
$backup = Backup::where('id', $this->selectedBackupIdForDelete)->where('user_id', $user->id)->first();
if (! $backup) {
Notification::make()->title(__('Backup not found'))->danger()->send();
return;
}
// Delete the file
if ($backup->local_path) {
try {
$this->getAgent()->backupDelete($user->username, $backup->local_path);
} catch (Exception $e) {
// Continue anyway
}
}
$backup->delete();
Notification::make()->title(__('Backup deleted'))->success()->send();
$this->resetTable();
});
}
public function confirmDeleteLocalFile(string $path): void
{
$this->selectedPathForDelete = $path;
$this->mountAction('deleteLocalFileAction');
}
public function deleteLocalFileAction(): Action
{
return Action::make('deleteLocalFileAction')
->requiresConfirmation()
->modalHeading(__('Delete Backup File'))
->modalDescription(__('Are you sure you want to delete this backup file? This action cannot be undone.'))
->modalIcon('heroicon-o-trash')
->modalIconColor('danger')
->modalSubmitActionLabel(__('Delete'))
->color('danger')
->action(function (): void {
$user = $this->getUser();
try {
$result = $this->getAgent()->backupDelete($user->username, $this->selectedPathForDelete);
if ($result['success']) {
Notification::make()->title(__('Backup deleted'))->success()->send();
} else {
throw new Exception($result['error'] ?? 'Delete failed');
}
} catch (Exception $e) {
Notification::make()->title(__('Delete failed'))->body($e->getMessage())->danger()->send();
}
$this->resetTable();
});
}
public function downloadFromRemote(int $destinationId, string $remotePath): void
{
$user = $this->getUser();
$destination = BackupDestination::find($destinationId);
if (! $destination) {
Notification::make()->title(__('Destination not found'))->danger()->send();
return;
}
// Create a timestamped tar.gz filename
// remotePath is like "2026-01-20_143000/user", extract the date part
$pathParts = explode('/', trim($remotePath, '/'));
$backupDate = $pathParts[0] ?? now()->format('Y-m-d_His');
$timestamp = now()->format('Y-m-d_His');
$filename = "backup_{$timestamp}.tar.gz";
$localPath = "/home/{$user->username}/backups/{$filename}";
try {
Notification::make()
->title(__('Downloading backup...'))
->body(__('This may take a few minutes. Please wait.'))
->info()
->send();
$config = array_merge($destination->config ?? [], ['type' => $destination->type]);
// Use the new agent action that creates a tar.gz archive
$result = $this->getAgent()->send('backup.download_user_archive', [
'username' => $user->username,
'remote_path' => $remotePath,
'destination' => $config,
'output_path' => $localPath,
]);
if ($result['success'] ?? false) {
// Create backup record
$backup = Backup::create([
'user_id' => $user->id,
'name' => "Server Backup ({$backupDate})",
'filename' => $filename,
'type' => 'full',
'status' => 'completed',
'local_path' => $localPath,
'size_bytes' => $result['size'] ?? 0,
'completed_at' => now(),
]);
// Format size for display
$sizeFormatted = $this->formatBytes($result['size'] ?? 0);
// Create download URL
$downloadUrl = url('/jabali-panel/backup-download?path='.base64_encode($localPath));
Notification::make()
->title(__('Backup Ready'))
->body(__('Your backup (:size) can be downloaded from My Backups or from your backups folder.', ['size' => $sizeFormatted]))
->success()
->persistent()
->actions([
\Filament\Actions\Action::make('download')
->label(__('Download'))
->url($downloadUrl)
->openUrlInNewTab()
->button(),
\Filament\Actions\Action::make('close')
->label(__('Close'))
->close()
->color('gray'),
])
->send();
$this->setTab('local');
$this->resetTable();
} else {
throw new Exception($result['error'] ?? 'Download failed');
}
} catch (Exception $e) {
Notification::make()->title(__('Download failed'))->body($e->getMessage())->danger()->send();
}
}
public function restoreBackupAction(): Action
{
return Action::make('restoreBackup')
->label(__('Restore'))
->icon('heroicon-o-arrow-path')
->color('warning')
->requiresConfirmation()
->modalHeading(__('Restore Backup'))
->modalDescription(__('Select what you want to restore from this backup. Existing data may be overwritten.'))
->modalIcon('heroicon-o-arrow-path')
->modalIconColor('warning')
->modalSubmitActionLabel(__('Start Restore'))
->form(function () {
$backup = $this->selectedBackupId ? Backup::find($this->selectedBackupId) : null;
$manifest = $backup ? [
'domains' => $backup->domains ?? [],
'databases' => $backup->databases ?? [],
'mailboxes' => $backup->mailboxes ?? [],
] : [];
return [
Section::make(__('Restore Options'))
->description(__('Choose which types of data to restore'))
->schema([
Grid::make(2)->schema([
Toggle::make('restore_files')
->label(__('Restore Website Files'))
->default(true)
->reactive()
->helperText(__('Restores all website files to their original locations')),
Toggle::make('restore_databases')
->label(__('Restore Databases'))
->default(true)
->reactive()
->helperText(__('Restores MySQL databases (overwrites existing data)')),
Toggle::make('restore_mailboxes')
->label(__('Restore Mailboxes'))
->default(true)
->helperText(__('Restores email accounts and messages')),
]),
]),
Section::make(__('Select Items'))
->description(__('Leave empty to restore all items'))
->schema([
CheckboxList::make('selected_domains')
->label(__('Domains to Restore'))
->options(fn () => array_combine($manifest['domains'] ?? [], $manifest['domains'] ?? []))
->visible(fn ($get) => $get('restore_files') && ! empty($manifest['domains']))
->helperText(__('Select specific domains or leave empty for all')),
CheckboxList::make('selected_databases')
->label(__('Databases to Restore'))
->options(fn () => array_combine($manifest['databases'] ?? [], $manifest['databases'] ?? []))
->visible(fn ($get) => $get('restore_databases') && ! empty($manifest['databases']))
->helperText(__('Select specific databases or leave empty for all')),
CheckboxList::make('selected_mailboxes')
->label(__('Mailboxes to Restore'))
->options(fn () => array_combine($manifest['mailboxes'] ?? [], $manifest['mailboxes'] ?? []))
->visible(fn ($get) => $get('restore_mailboxes') && ! empty($manifest['mailboxes']))
->helperText(__('Select specific mailboxes or leave empty for all')),
]),
];
})
->action(function (array $data) {
$this->performRestore($data);
});
}
public function startRestore(int $backupId): void
{
$this->selectedBackupId = $backupId;
$this->mountAction('restoreBackupAction');
}
public function performRestore(array $data): void
{
$user = $this->getUser();
$backup = Backup::find($this->selectedBackupId);
if (! $backup || $backup->user_id !== $user->id) {
Notification::make()->title(__('Backup not found'))->danger()->send();
return;
}
$restore = BackupRestore::create([
'backup_id' => $backup->id,
'user_id' => $user->id,
'restore_files' => $data['restore_files'] ?? true,
'restore_databases' => $data['restore_databases'] ?? true,
'restore_mailboxes' => $data['restore_mailboxes'] ?? true,
'selected_domains' => ! empty($data['selected_domains']) ? $data['selected_domains'] : null,
'selected_databases' => ! empty($data['selected_databases']) ? $data['selected_databases'] : null,
'selected_mailboxes' => ! empty($data['selected_mailboxes']) ? $data['selected_mailboxes'] : null,
'status' => 'pending',
]);
try {
$restore->markAsRunning();
$result = $this->getAgent()->backupRestore($user->username, $backup->local_path, [
'restore_files' => $data['restore_files'] ?? true,
'restore_databases' => $data['restore_databases'] ?? true,
'restore_mailboxes' => $data['restore_mailboxes'] ?? true,
'selected_domains' => ! empty($data['selected_domains']) ? $data['selected_domains'] : null,
'selected_databases' => ! empty($data['selected_databases']) ? $data['selected_databases'] : null,
'selected_mailboxes' => ! empty($data['selected_mailboxes']) ? $data['selected_mailboxes'] : null,
]);
if ($result['success']) {
$restore->markAsCompleted($result['restored'] ?? []);
Notification::make()
->title(__('Restore completed'))
->body(__('Restored: :domains domains, :databases databases, :mailboxes mailboxes', [
'domains' => $result['files_count'],
'databases' => $result['databases_count'],
'mailboxes' => $result['mailboxes_count'],
]))
->success()
->send();
} else {
throw new Exception($result['error'] ?? 'Restore failed');
}
} catch (Exception $e) {
$restore->markAsFailed($e->getMessage());
Notification::make()->title(__('Restore failed'))->body($e->getMessage())->danger()->send();
}
$this->resetTable();
}
protected function addDestinationAction(): Action
{
return Action::make('addDestination')
->label(__('Add SFTP'))
->icon('heroicon-o-plus')
->color('primary')
->modalHeading(__('Add SFTP Destination'))
->modalDescription(__('Configure an SFTP server to store your backups remotely.'))
->form([
TextInput::make('name')
->label(__('Name'))
->placeholder(__('My Backup Server'))
->required(),
Grid::make(2)->schema([
TextInput::make('host')
->label(__('Host'))
->placeholder(__('backup.example.com'))
->required(),
TextInput::make('port')
->label(__('Port'))
->numeric()
->default(22),
]),
TextInput::make('username')
->label(__('Username'))
->required(),
TextInput::make('password')
->label(__('Password'))
->password()
->helperText(__('Leave empty if using SSH key')),
Textarea::make('private_key')
->label(__('SSH Private Key'))
->rows(4)
->helperText(__('Paste your private key here (optional)')),
TextInput::make('path')
->label(__('Remote Path'))
->default('/backups')
->helperText(__('Directory on the server to store backups')),
FormActions::make([
Action::make('testConnection')
->label(__('Test Connection'))
->icon('heroicon-o-signal')
->color('gray')
->action(function ($get, $livewire) {
$config = [
'type' => 'sftp',
'host' => $get('host') ?? '',
'port' => (int) ($get('port') ?? 22),
'username' => $get('username') ?? '',
'password' => $get('password') ?? '',
'private_key' => $get('private_key') ?? '',
'path' => $get('path') ?? '/backups',
];
try {
$result = $livewire->getAgent()->backupTestDestination($config);
if ($result['success']) {
Notification::make()
->title(__('Connection successful'))
->success()
->send();
} else {
Notification::make()
->title(__('Connection failed'))
->body($result['error'] ?? __('Could not connect'))
->danger()
->send();
}
} catch (Exception $e) {
Notification::make()
->title(__('Connection failed'))
->body($e->getMessage())
->danger()
->send();
}
}),
])->visible(fn ($get) => ! empty($get('host'))),
])
->action(function (array $data) {
$user = $this->getUser();
// Test connection first
$config = [
'type' => 'sftp',
'host' => $data['host'] ?? '',
'port' => (int) ($data['port'] ?? 22),
'username' => $data['username'] ?? '',
'password' => $data['password'] ?? '',
'private_key' => $data['private_key'] ?? '',
'path' => $data['path'] ?? '/backups',
];
try {
$result = $this->getAgent()->backupTestDestination($config);
if (! $result['success']) {
Notification::make()
->title(__('Connection failed'))
->body($result['error'] ?? __('Could not connect to SFTP server'))
->danger()
->send();
return;
}
} catch (Exception $e) {
Notification::make()
->title(__('Connection failed'))
->body($e->getMessage())
->danger()
->send();
return;
}
BackupDestination::create([
'user_id' => $user->id,
'name' => $data['name'],
'type' => 'sftp',
'config' => $config,
'is_server_backup' => false,
'is_active' => true,
'last_tested_at' => now(),
'test_status' => 'success',
]);
Notification::make()->title(__('SFTP destination added'))->success()->send();
$this->resetTable();
});
}
public function testUserDestination(int $id): void
{
$user = $this->getUser();
$destination = BackupDestination::where('id', $id)->where('user_id', $user->id)->first();
if (! $destination) {
Notification::make()->title(__('Destination not found'))->danger()->send();
return;
}
try {
$config = array_merge($destination->config ?? [], ['type' => $destination->type]);
$result = $this->getAgent()->backupTestDestination($config);
$destination->update([
'last_tested_at' => now(),
'test_status' => $result['success'] ? 'success' : 'failed',
'test_message' => $result['message'] ?? $result['error'] ?? null,
]);
if ($result['success']) {
Notification::make()->title(__('Connection successful'))->success()->send();
} else {
Notification::make()->title(__('Connection failed'))->body($result['error'] ?? __('Unknown error'))->danger()->send();
}
} catch (Exception $e) {
$destination->update([
'last_tested_at' => now(),
'test_status' => 'failed',
'test_message' => $e->getMessage(),
]);
Notification::make()->title(__('Test failed'))->body($e->getMessage())->danger()->send();
}
$this->resetTable();
}
public function deleteUserDestination(int $id): void
{
$user = $this->getUser();
BackupDestination::where('id', $id)->where('user_id', $user->id)->delete();
Notification::make()->title(__('Destination deleted'))->success()->send();
$this->resetTable();
}
protected function formatBytes(int $bytes, int $precision = 2): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, $precision).' '.$units[$pow];
}
}