agent === null) { $this->agent = new AgentClient(); } return $this->agent; } public function getUsername(): string { return Auth::user()->username; } public function table(Table $table): Table { return $table ->query(Domain::query()->where('user_id', Auth::id())) ->columns([ TextColumn::make('domain') ->label(__('Domain')) ->icon('heroicon-o-globe-alt') ->iconColor('primary') ->description(fn (Domain $record) => $record->document_root) ->url(fn (Domain $record) => 'http://' . $record->domain, shouldOpenInNewTab: true) ->searchable() ->sortable(), IconColumn::make('is_active') ->label(__('Status')) ->boolean() ->trueIcon('heroicon-o-check-circle') ->falseIcon('heroicon-o-x-circle') ->trueColor('success') ->falseColor('danger'), IconColumn::make('ssl_enabled') ->label(__('SSL')) ->boolean() ->trueIcon('heroicon-m-lock-closed') ->falseIcon('heroicon-m-lock-open') ->trueColor('success') ->falseColor('warning'), IconColumn::make('page_cache_enabled') ->label(__('Page Cache')) ->boolean() ->trueIcon('heroicon-o-bolt') ->falseIcon('heroicon-o-bolt-slash') ->trueColor('success') ->falseColor('gray'), TextColumn::make('redirects_count') ->label(__('Redirects')) ->counts('redirects') ->badge() ->color('info'), TextColumn::make('created_at') ->label(__('Created')) ->date('M d, Y') ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->recordActions([ ActionGroup::make([ Action::make('files') ->label(__('Files')) ->icon('heroicon-o-folder') ->color('info') ->action(fn (Domain $record) => $this->openFileManager($record)), Action::make('redirects') ->label(__('Redirects')) ->icon('heroicon-o-arrow-right-circle') ->color('warning') ->modalHeading(fn (Domain $record) => __('Redirects for :domain', ['domain' => $record->domain])) ->modalDescription(__('Redirect this domain to another domain or set up page redirects')) ->modalWidth(Width::FourExtraLarge) ->modalSubmitActionLabel(__('Save Redirects')) ->form(fn (Domain $record) => $this->getRedirectsForm($record)) ->fillForm(fn (Domain $record) => $this->getRedirectsFormData($record)) ->action(fn (Domain $record, array $data) => $this->saveRedirects($record, $data)), Action::make('hotlink') ->label(__('Hotlink Protection')) ->icon('heroicon-o-shield-check') ->color('success') ->modalHeading(fn (Domain $record) => __('Hotlink Protection for :domain', ['domain' => $record->domain])) ->modalDescription(__('Prevent other websites from directly linking to your files')) ->modalWidth(Width::TwoExtraLarge) ->form($this->getHotlinkForm()) ->fillForm(fn (Domain $record) => $this->getHotlinkFormData($record)) ->action(fn (Domain $record, array $data) => $this->saveHotlinkSettings($record, $data)), Action::make('index') ->label(__('Index Manager')) ->icon('heroicon-o-document-text') ->color('gray') ->modalHeading(fn (Domain $record) => __('Index Manager for :domain', ['domain' => $record->domain])) ->modalDescription(__('Set the default directory index files')) ->modalWidth(Width::Medium) ->form($this->getIndexForm()) ->fillForm(fn (Domain $record) => ['directory_index' => $record->directory_index]) ->action(fn (Domain $record, array $data) => $this->saveIndexSettings($record, $data)), ]) ->label(__('Settings')) ->icon('heroicon-o-cog-6-tooth') ->color('gray') ->button(), Action::make('toggle') ->label(fn (Domain $record) => $record->is_active ? __('Disable') : __('Enable')) ->icon(fn (Domain $record) => $record->is_active ? 'heroicon-o-no-symbol' : 'heroicon-o-check') ->color(fn (Domain $record) => $record->is_active ? 'warning' : 'success') ->action(fn (Domain $record) => $this->toggleDomain($record)), Action::make('delete') ->label(__('Delete')) ->icon('heroicon-o-trash') ->color('danger') ->requiresConfirmation() ->modalHeading(__('Delete Domain')) ->modalDescription(fn (Domain $record) => __('Are you sure you want to delete') . " '{$record->domain}'? " . __('This will also delete the following associated data:')) ->modalIcon('heroicon-o-trash') ->modalIconColor('danger') ->modalSubmitActionLabel(__('Delete Domain')) ->modalWidth(Width::Large) ->form(fn (Domain $record): array => [ Toggle::make('delete_files') ->label(__('Delete all domain files')) ->helperText(__('Permanently delete all files in the domain folder')) ->default(true), Toggle::make('delete_dns') ->label(__('Delete DNS records') . ' (' . $record->dnsRecords()->count() . ')') ->helperText(__('Remove all DNS records for this domain')) ->default(true) ->visible(fn () => $record->dnsRecords()->exists()), Toggle::make('delete_email') ->label(__('Delete email accounts') . ' (' . ($record->emailDomain?->mailboxes()->count() ?? 0) . ')') ->helperText(__('Remove all mailboxes and email configuration')) ->default(true) ->visible(fn () => $record->emailDomain()->exists()), Toggle::make('delete_ssl') ->label(__('Delete SSL certificate')) ->helperText(__('Remove SSL certificate for this domain')) ->default(true) ->visible(fn () => $record->sslCertificate()->exists()), Toggle::make('delete_wordpress') ->label(__('Delete WordPress sites')) ->helperText(__('Remove all WordPress installations on this domain')) ->default(true), ]) ->action(fn (Domain $record, array $data) => $this->deleteDomain($record, $data)), ]) ->emptyStateHeading(__('No domains yet')) ->emptyStateDescription(__('Click "Add Domain" to add your first domain')) ->emptyStateIcon('heroicon-o-globe-alt') ->striped() ->defaultSort('created_at', 'desc'); } protected function getRedirectsForm(Domain $record): array { return [ // Domain-wide redirect Toggle::make('domain_redirect_enabled') ->label(__('Redirect Entire Domain')) ->helperText(__('Redirect all traffic from this domain to another domain')) ->live() ->columnSpanFull(), Grid::make() ->schema([ TextInput::make('domain_redirect_url') ->label(__('Redirect To')) ->placeholder('https://newdomain.com') ->helperText(__('All requests to this domain will be redirected to this URL')) ->url() ->required(fn ($get) => $get('domain_redirect_enabled')) ->columnSpan(['default' => 2, 'md' => 1]), Select::make('domain_redirect_type') ->label(__('Redirect Type')) ->options([ '301' => __('Permanent (301) - SEO friendly'), '302' => __('Temporary (302)'), ]) ->default('301') ->required(fn ($get) => $get('domain_redirect_enabled')) ->columnSpan(['default' => 2, 'md' => 1]), ]) ->columns(['default' => 2, 'md' => 2]) ->visible(fn ($get) => $get('domain_redirect_enabled')), // Page redirects Repeater::make('redirects') ->label(__('Page Redirects')) ->helperText(__('Redirect specific paths to other URLs')) ->schema([ Grid::make() ->schema([ TextInput::make('source_path') ->label(__('Source Path')) ->placeholder('/old-page') ->helperText(__('Path to redirect from (e.g., /old-page)')) ->required() ->columnSpan(['default' => 2, 'md' => 1]), TextInput::make('destination_url') ->label(__('Destination URL')) ->placeholder('https://example.com/new-page') ->helperText(__('Full URL to redirect to')) ->required() ->url() ->columnSpan(['default' => 2, 'md' => 1]), ]) ->columns(['default' => 2, 'md' => 2]), Grid::make() ->schema([ Select::make('redirect_type') ->label(__('Type')) ->options([ '301' => __('Permanent (301)'), '302' => __('Temporary (302)'), ]) ->default('301') ->required() ->columnSpan(['default' => 2, 'sm' => 1]), Toggle::make('is_wildcard') ->label(__('Wildcard')) ->helperText(__('Match all paths starting with source')) ->columnSpan(['default' => 2, 'sm' => 1]), Toggle::make('is_active') ->label(__('Active')) ->default(true) ->columnSpan(['default' => 2, 'sm' => 1]), ]) ->columns(['default' => 2, 'sm' => 3]), ]) ->itemLabel(fn (array $state): ?string => ($state['source_path'] ?? '') . ' → ' . ($state['redirect_type'] ?? '301')) ->collapsible() ->collapsed(fn () => $record->redirects()->count() > 3) ->addActionLabel(__('Add Page Redirect')) ->reorderable() ->defaultItems(0) ->visible(fn ($get) => !$get('domain_redirect_enabled')), ]; } protected function getRedirectsFormData(Domain $record): array { // Check if there's a domain-wide redirect (source_path = '/*' or '*') $domainRedirect = $record->redirects() ->whereIn('source_path', ['/*', '*', '/']) ->where('is_wildcard', true) ->first(); return [ 'domain_redirect_enabled' => $domainRedirect !== null, 'domain_redirect_url' => $domainRedirect?->destination_url ?? '', 'domain_redirect_type' => $domainRedirect?->redirect_type ?? '301', 'redirects' => $record->redirects() ->whereNotIn('source_path', ['/*', '*', '/']) ->orWhere('is_wildcard', false) ->get() ->map(fn ($r) => [ 'id' => $r->id, 'source_path' => $r->source_path, 'destination_url' => $r->destination_url, 'redirect_type' => $r->redirect_type, 'is_wildcard' => $r->is_wildcard, 'is_active' => $r->is_active, ])->toArray(), ]; } protected function getHotlinkForm(): array { return [ Toggle::make('is_enabled') ->label(__('Enable Hotlink Protection')) ->helperText(__('Block other websites from directly linking to your images and files')) ->live(), Grid::make() ->schema([ Textarea::make('allowed_domains') ->label(__('Allowed Domains')) ->helperText(__('One domain per line that can link to your files (your own domain is always allowed)')) ->placeholder("example.com\ntrusted-site.com") ->rows(4) ->columnSpan(['default' => 2, 'md' => 1]), TextInput::make('protected_extensions') ->label(__('Protected File Extensions')) ->helperText(__('Comma-separated list of file extensions to protect')) ->placeholder('jpg,jpeg,png,gif,webp,svg,mp4,mp3,pdf') ->default(DomainHotlinkSetting::getDefaultExtensions()) ->columnSpan(['default' => 2, 'md' => 1]), ]) ->columns(['default' => 2, 'md' => 2]) ->visible(fn ($get) => $get('is_enabled')), Grid::make() ->schema([ Toggle::make('block_blank_referrer') ->label(__('Block Blank Referrer')) ->helperText(__('Block requests with no referrer header')) ->default(true) ->columnSpan(['default' => 2, 'md' => 1]), TextInput::make('redirect_url') ->label(__('Redirect URL (Optional)')) ->helperText(__('Redirect blocked requests to this URL instead of showing an error')) ->placeholder('https://example.com/hotlink-blocked.png') ->url() ->columnSpan(['default' => 2, 'md' => 1]), ]) ->columns(['default' => 2, 'md' => 2]) ->visible(fn ($get) => $get('is_enabled')), ]; } protected function getHotlinkFormData(Domain $record): array { $setting = $record->hotlinkSetting; if (!$setting) { return [ 'is_enabled' => false, 'allowed_domains' => '', 'block_blank_referrer' => true, 'protected_extensions' => DomainHotlinkSetting::getDefaultExtensions(), 'redirect_url' => '', ]; } return [ 'is_enabled' => $setting->is_enabled, 'allowed_domains' => $setting->allowed_domains, 'block_blank_referrer' => $setting->block_blank_referrer, 'protected_extensions' => $setting->protected_extensions, 'redirect_url' => $setting->redirect_url ?? '', ]; } protected function getIndexForm(): array { return [ Radio::make('directory_index') ->label(__('Directory Index Priority')) ->helperText(__('Choose which file should be served as the default index')) ->options([ 'index.php index.html' => __('PHP first (index.php, then index.html)'), 'index.html index.php' => __('HTML first (index.html, then index.php)'), 'index.php' => __('PHP only (index.php)'), 'index.html' => __('HTML only (index.html)'), 'index.php index.html index.htm' => __('PHP, HTML, HTM (full support)'), ]) ->default('index.php index.html') ->required(), ]; } protected function getHeaderActions(): array { return [ $this->getTourAction(), $this->createDomainAction(), ]; } protected function createDomainAction(): Action { return Action::make('createDomain') ->label(__('Add Domain')) ->icon('heroicon-o-plus-circle') ->color('primary') ->modalHeading(__('Add Domain')) ->modalDescription(__('Add a new domain to your hosting account')) ->modalIcon('heroicon-o-globe-alt') ->modalIconColor('primary') ->modalSubmitActionLabel(__('Add Domain')) ->form([ TextInput::make('domain') ->label(__('Domain Name')) ->placeholder(__('example.com')) ->required() ->regex('/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/') ->helperText(__('Enter the domain name without http:// or www')), ]) ->action(function (array $data): void { try { $result = $this->getAgent()->domainCreate($this->getUsername(), $data['domain']); if ($result['success'] ?? false) { Domain::create([ 'user_id' => Auth::id(), 'domain' => $data['domain'], 'document_root' => '/home/' . $this->getUsername() . '/domains/' . $data['domain'] . '/public_html', 'is_active' => true, 'ssl_enabled' => false, 'directory_index' => 'index.php index.html', 'page_cache_enabled' => false, ]); Notification::make() ->title(__('Domain created!')) ->body(__('Your domain is now active.')) ->success() ->send(); } else { throw new Exception($result['error'] ?? 'Unknown error'); } } catch (Exception $e) { Notification::make() ->title(__('Error creating domain')) ->body($e->getMessage()) ->danger() ->send(); } }); } public function saveRedirects(Domain $domain, array $data): void { try { $existingIds = []; // Handle domain-wide redirect if ($data['domain_redirect_enabled'] ?? false) { // Delete all existing redirects and create a single domain-wide redirect $domain->redirects()->delete(); $redirect = $domain->redirects()->create([ 'source_path' => '/*', 'destination_url' => $data['domain_redirect_url'], 'redirect_type' => $data['domain_redirect_type'] ?? '301', 'is_wildcard' => true, 'is_active' => true, ]); $existingIds[] = $redirect->id; } else { // Delete any domain-wide redirects $domain->redirects() ->whereIn('source_path', ['/*', '*', '/']) ->where('is_wildcard', true) ->delete(); // Handle page redirects $redirectsData = $data['redirects'] ?? []; foreach ($redirectsData as $redirectData) { if (!empty($redirectData['id'])) { $redirect = DomainRedirect::find($redirectData['id']); if ($redirect && $redirect->domain_id === $domain->id) { $redirect->update([ 'source_path' => $redirectData['source_path'], 'destination_url' => $redirectData['destination_url'], 'redirect_type' => $redirectData['redirect_type'], 'is_wildcard' => $redirectData['is_wildcard'] ?? false, 'is_active' => $redirectData['is_active'] ?? true, ]); $existingIds[] = $redirect->id; } } else { $redirect = $domain->redirects()->create([ 'source_path' => $redirectData['source_path'], 'destination_url' => $redirectData['destination_url'], 'redirect_type' => $redirectData['redirect_type'], 'is_wildcard' => $redirectData['is_wildcard'] ?? false, 'is_active' => $redirectData['is_active'] ?? true, ]); $existingIds[] = $redirect->id; } } // Delete removed page redirects (but not domain-wide ones which we already handled) if (!empty($existingIds)) { $domain->redirects() ->whereNotIn('id', $existingIds) ->whereNotIn('source_path', ['/*', '*', '/']) ->delete(); } } // Apply redirects via agent $this->applyRedirects($domain); Notification::make() ->title(__('Redirects saved!')) ->body(__('Your redirect rules have been updated.')) ->success() ->send(); } catch (Exception $e) { Notification::make() ->title(__('Error saving redirects')) ->body($e->getMessage()) ->danger() ->send(); } } protected function applyRedirects(Domain $domain): void { $redirects = $domain->redirects()->where('is_active', true)->get()->map(fn ($r) => [ 'source' => $r->source_path, 'destination' => $r->destination_url, 'type' => $r->redirect_type, 'wildcard' => $r->is_wildcard, ])->toArray(); $this->getAgent()->send('domain.set_redirects', [ 'username' => $this->getUsername(), 'domain' => $domain->domain, 'redirects' => $redirects, ]); } public function saveHotlinkSettings(Domain $domain, array $data): void { try { $setting = $domain->hotlinkSetting; if (!$setting) { $setting = new DomainHotlinkSetting(['domain_id' => $domain->id]); } $setting->fill([ 'is_enabled' => $data['is_enabled'] ?? false, 'allowed_domains' => $data['allowed_domains'] ?? '', 'block_blank_referrer' => $data['block_blank_referrer'] ?? true, 'protected_extensions' => $data['protected_extensions'] ?? DomainHotlinkSetting::getDefaultExtensions(), 'redirect_url' => $data['redirect_url'] ?? null, ]); $setting->save(); // Apply hotlink protection via agent $this->getAgent()->send('domain.set_hotlink_protection', [ 'username' => $this->getUsername(), 'domain' => $domain->domain, 'enabled' => $setting->is_enabled, 'allowed_domains' => $setting->getAllowedDomainsArray(), 'block_blank_referrer' => $setting->block_blank_referrer, 'protected_extensions' => $setting->getProtectedExtensionsArray(), 'redirect_url' => $setting->redirect_url, ]); Notification::make() ->title(__('Hotlink protection updated!')) ->body($setting->is_enabled ? __('Protection is now active.') : __('Protection has been disabled.')) ->success() ->send(); } catch (Exception $e) { Notification::make() ->title(__('Error saving hotlink settings')) ->body($e->getMessage()) ->danger() ->send(); } } public function saveIndexSettings(Domain $domain, array $data): void { try { $domain->update(['directory_index' => $data['directory_index']]); // Apply index settings via agent $this->getAgent()->send('domain.set_directory_index', [ 'username' => $this->getUsername(), 'domain' => $domain->domain, 'directory_index' => $data['directory_index'], ]); Notification::make() ->title(__('Index settings updated!')) ->body(__('Directory index priority has been changed.')) ->success() ->send(); } catch (Exception $e) { Notification::make() ->title(__('Error saving index settings')) ->body($e->getMessage()) ->danger() ->send(); } } public function toggleDomain(Domain $domain): void { try { $newStatus = !$domain->is_active; $result = $this->getAgent()->domainToggle($this->getUsername(), $domain->domain, $newStatus); if ($result['success'] ?? false) { $domain->update(['is_active' => $newStatus]); $status = $newStatus ? __('Enabled') : __('Disabled'); Notification::make() ->title(__('Domain') . " {$status}") ->success() ->send(); } else { throw new Exception($result['error'] ?? 'Unknown error'); } } catch (Exception $e) { Notification::make() ->title(__('Error toggling domain')) ->body($e->getMessage()) ->danger() ->send(); } } public function deleteDomain(Domain $domain, array $options): void { $deletedItems = []; $errors = []; try { // Delete WordPress sites first (via Agent) if ($options['delete_wordpress'] ?? false) { try { $wpResult = $this->getAgent()->send('wp.list', [ 'username' => $this->getUsername(), ]); foreach ($wpResult['sites'] ?? [] as $site) { if (($site['domain'] ?? '') === $domain->domain) { $this->getAgent()->send('wp.delete', [ 'username' => $this->getUsername(), 'site_id' => $site['id'], 'delete_files' => true, 'delete_database' => true, ]); $deletedItems[] = __('WordPress site'); } } } catch (Exception $e) { $errors[] = __('WordPress: ') . $e->getMessage(); } } // Delete SSL certificate if ($options['delete_ssl'] ?? false) { if ($domain->sslCertificate) { try { $this->getAgent()->send('ssl.delete', [ 'username' => $this->getUsername(), 'domain' => $domain->domain, ]); $domain->sslCertificate->delete(); $deletedItems[] = __('SSL certificate'); } catch (Exception $e) { $errors[] = __('SSL: ') . $e->getMessage(); } } } // Delete email accounts if ($options['delete_email'] ?? false) { if ($domain->emailDomain) { try { foreach ($domain->emailDomain->mailboxes as $mailbox) { $this->getAgent()->send('email.mailbox_delete', [ 'username' => $this->getUsername(), 'email' => $mailbox->email, 'delete_files' => true, 'maildir_path' => $mailbox->maildir_path, ]); } $this->getAgent()->send('email.disable_domain', [ 'username' => $this->getUsername(), 'domain' => $domain->domain, ]); $mailboxCount = $domain->emailDomain->mailboxes()->count(); $domain->emailDomain->mailboxes()->delete(); $domain->emailDomain->delete(); $deletedItems[] = __(':count email account(s)', ['count' => $mailboxCount]); } catch (Exception $e) { $errors[] = __('Email: ') . $e->getMessage(); } } } // Delete DNS records if ($options['delete_dns'] ?? false) { try { $dnsCount = $domain->dnsRecords()->count(); if ($dnsCount > 0) { $this->getAgent()->send('dns.delete_zone', [ 'domain' => $domain->domain, ]); $domain->dnsRecords()->delete(); $deletedItems[] = __(':count DNS record(s)', ['count' => $dnsCount]); } } catch (Exception $e) { $errors[] = __('DNS: ') . $e->getMessage(); } } // Delete redirects and hotlink settings (cascade should handle this, but be explicit) $domain->redirects()->delete(); $domain->hotlinkSetting?->delete(); // Delete domain files and configuration via Agent $result = $this->getAgent()->domainDelete( $this->getUsername(), $domain->domain, $options['delete_files'] ?? false ); if ($result['success'] ?? false) { $domain->delete(); $message = __('Domain deleted successfully.'); if (!empty($deletedItems)) { $message .= ' ' . __('Also deleted: ') . implode(', ', $deletedItems); } Notification::make() ->title(__('Domain deleted')) ->body($message) ->success() ->send(); if (!empty($errors)) { Notification::make() ->title(__('Some items had warnings')) ->body(implode("\n", $errors)) ->warning() ->send(); } } else { throw new Exception($result['error'] ?? 'Unknown error'); } } catch (Exception $e) { Notification::make() ->title(__('Error deleting domain')) ->body($e->getMessage()) ->danger() ->send(); } } public function openFileManager(Domain $domain): void { $path = str_replace('/home/' . $this->getUsername() . '/', '', $domain->document_root); $this->redirect(route('filament.jabali.pages.files', ['path' => $path])); } }