Files
jabali-panel/app/Filament/Jabali/Pages/WordPress.php
2026-01-28 04:19:30 +02:00

1459 lines
60 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Models\Domain;
use App\Models\MysqlCredential;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ViewColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
class WordPress extends Page implements HasActions, HasForms, HasTable
{
protected static ?string $slug = 'wordpress';
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-pencil-square';
protected static ?int $navigationSort = 4;
public static function getNavigationLabel(): string
{
return __('WordPress');
}
protected string $view = 'filament.jabali.pages.wordpress';
public array $sites = [];
public array $domains = [];
public ?string $selectedSiteId = null;
// Credentials modal
public bool $showCredentials = false;
public array $credentials = [];
// Scan modal
public bool $showScanModal = false;
public array $scannedSites = [];
public bool $isScanning = false;
// Security scan
public bool $showSecurityScanModal = false;
public array $securityScanResults = [];
public bool $isSecurityScanning = false;
public ?string $scanningSiteId = null;
public ?string $scanningSiteUrl = null;
protected ?AgentClient $agent = null;
public function getTitle(): string|Htmlable
{
return __('WordPress Manager');
}
public function getAgent(): AgentClient
{
if ($this->agent === null) {
$this->agent = new AgentClient;
}
return $this->agent;
}
public function getUsername(): string
{
return Auth::user()->username;
}
public function mount(): void
{
$this->loadData();
}
public function loadData(): void
{
// Load WordPress sites
try {
$result = $this->getAgent()->wpList($this->getUsername());
$this->sites = $result['sites'] ?? [];
} catch (Exception $e) {
$this->sites = [];
}
// Load domains for the install form
try {
$result = $this->getAgent()->domainList($this->getUsername());
$this->domains = $result['domains'] ?? [];
} catch (Exception $e) {
$this->domains = [];
}
}
public function table(Table $table): Table
{
return $table
->records(fn () => collect($this->sites)->keyBy('id')->toArray())
->columns([
ViewColumn::make('screenshot')
->label(__('Preview'))
->view('filament.jabali.columns.wordpress-screenshot'),
ViewColumn::make('domain')
->label(__('Site'))
->view('filament.jabali.columns.wordpress-site'),
TextColumn::make('cache_enabled')
->label(__('Cache'))
->badge()
->formatStateUsing(fn (bool $state): string => $state ? __('On') : __('Off'))
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
TextColumn::make('debug_enabled')
->label(__('Debug'))
->badge()
->getStateUsing(fn (array $record): bool => $record['debug_enabled'] ?? false)
->formatStateUsing(fn (bool $state): string => $state ? __('On') : __('Off'))
->color(fn (bool $state): string => $state ? 'warning' : 'gray'),
TextColumn::make('auto_update')
->label(__('Updates'))
->badge()
->getStateUsing(fn (array $record): bool => $record['auto_update'] ?? false)
->formatStateUsing(fn (bool $state): string => $state ? __('Auto') : __('Manual'))
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
])
->recordActions([
Action::make('admin')
->label(__('Admin'))
->icon('heroicon-o-arrow-right-on-rectangle')
->action(fn (array $record) => $this->autoLogin($record['id'])),
ActionGroup::make([
Action::make('update')
->label(__('Update Now'))
->icon('heroicon-o-arrow-path')
->action(fn (array $record) => $this->updateWordPress($record['id'])),
Action::make('cache')
->label(fn (array $record): string => ($record['cache_enabled'] ?? false) ? __('Disable Cache') : __('Enable Cache'))
->icon('heroicon-o-bolt')
->modalHeading(fn (array $record): string => ($record['cache_enabled'] ?? false) ? __('Disable Jabali Cache') : __('Enable Jabali Cache'))
->modalDescription(fn (array $record): string => ($record['cache_enabled'] ?? false)
? __('This will disable caching for this WordPress site.')
: __('This will enable Redis object caching and nginx page caching for better performance.'))
->modalWidth('md')
->form(fn (array $record): array => ($record['cache_enabled'] ?? false)
? [
Toggle::make('remove_plugin')
->label(__('Uninstall Jabali Cache plugin'))
->helperText(__('Completely remove the plugin files from WordPress. If unchecked, the plugin will only be deactivated.'))
->default(false)
->live(),
Toggle::make('reset_data')
->label(__('Delete plugin settings'))
->helperText(__('Remove all Jabali Cache settings from the database.'))
->default(false)
->visible(fn ($get) => $get('remove_plugin')),
]
: [])
->action(fn (array $record, array $data) => $this->toggleCache($record['id'], $data['remove_plugin'] ?? false, $data['reset_data'] ?? false)),
Action::make('debug')
->label(fn (array $record): string => ($record['debug_enabled'] ?? false) ? __('Disable Debug') : __('Enable Debug'))
->icon('heroicon-o-bug-ant')
->color('warning')
->action(fn (array $record) => $this->toggleDebug($record['id'])),
Action::make('autoUpdate')
->label(fn (array $record): string => ($record['auto_update'] ?? false) ? __('Disable Auto-Update') : __('Enable Auto-Update'))
->icon('heroicon-o-arrow-path')
->action(fn (array $record) => $this->toggleAutoUpdate($record['id'])),
Action::make('staging')
->label(__('Create Staging'))
->icon('heroicon-o-document-duplicate')
->color('info')
->requiresConfirmation()
->modalHeading(__('Create Staging Environment'))
->modalDescription(__('This will create a copy of your site for testing.'))
->modalIcon('heroicon-o-document-duplicate')
->modalIconColor('info')
->form([
TextInput::make('staging_subdomain')
->label(__('Staging Subdomain'))
->prefix('staging-')
->suffix(fn (array $record): string => '.'.($record['domain'] ?? ''))
->default('test')
->required()
->alphaNum(),
])
->action(fn (array $data, array $record) => $this->createStaging($record['id'], $data['staging_subdomain'])),
Action::make('pushStaging')
->label(__('Push to Production'))
->icon('heroicon-o-arrow-up-tray')
->color('warning')
->requiresConfirmation()
->visible(fn (array $record): bool => (bool) ($record['is_staging'] ?? false))
->modalHeading(__('Push Staging to Production'))
->modalDescription(__('This will replace the live site files and database with the staging version.'))
->modalIcon('heroicon-o-arrow-up-tray')
->modalIconColor('warning')
->action(fn (array $record) => $this->pushStaging($record['id'])),
Action::make('security')
->label(__('Security Scan'))
->icon('heroicon-o-shield-check')
->action(fn (array $record) => $this->runSecurityScan($record['id'])),
Action::make('delete')
->label(__('Delete Site'))
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->modalHeading(__('Delete WordPress Site'))
->modalDescription(__('Are you sure you want to delete this WordPress installation? This action cannot be undone.'))
->modalIcon('heroicon-o-trash')
->modalIconColor('danger')
->form([
Toggle::make('delete_files')
->label(__('Delete all files'))
->default(true)
->helperText(__('Permanently remove all WordPress files from the server')),
Toggle::make('delete_database')
->label(__('Delete database'))
->default(true)
->helperText(__('Permanently delete the WordPress database and all content')),
])
->action(function (array $data, array $record): void {
try {
$result = $this->getAgent()->wpDelete(
$this->getUsername(),
$record['id'],
$data['delete_files'] ?? true,
$data['delete_database'] ?? true
);
if ($result['success'] ?? false) {
// Delete screenshot if exists
$screenshotPath = storage_path('app/public/screenshots/wp-'.$record['id'].'.png');
if (file_exists($screenshotPath)) {
@unlink($screenshotPath);
}
Notification::make()
->title(__('WordPress Deleted'))
->success()
->send();
$this->loadData();
$this->resetTable();
} else {
throw new Exception($result['error'] ?? __('Deletion failed'));
}
} catch (Exception $e) {
Notification::make()
->title(__('Deletion Failed'))
->body($e->getMessage())
->danger()
->send();
}
}),
])
->icon('heroicon-o-ellipsis-vertical')
->color('gray')
->iconButton(),
])
->emptyStateHeading(__('No WordPress Sites'))
->emptyStateDescription(__('Click "Install WordPress" to create your first site'))
->emptyStateIcon('heroicon-o-globe-alt');
}
public function getTableRecordKey(\Illuminate\Database\Eloquent\Model|array $record): string
{
return is_array($record) ? ($record['id'] ?? '') : $record->getKey();
}
public function generateSecurePassword(int $length = 16): string
{
$lowercase = 'abcdefghijklmnopqrstuvwxyz';
$uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$numbers = '0123456789';
$special = '!@#$%^&*';
// Ensure at least one of each required type
$password = $lowercase[random_int(0, strlen($lowercase) - 1)]
.$uppercase[random_int(0, strlen($uppercase) - 1)]
.$numbers[random_int(0, strlen($numbers) - 1)]
.$special[random_int(0, strlen($special) - 1)];
// Fill the rest with random characters from all types
$allChars = $lowercase.$uppercase.$numbers.$special;
for ($i = strlen($password); $i < $length; $i++) {
$password .= $allChars[random_int(0, strlen($allChars) - 1)];
}
// Shuffle the password to randomize position of required characters
return str_shuffle($password);
}
public function closeCredentials(): void
{
$this->showCredentials = false;
$this->credentials = [];
}
protected function getHeaderActions(): array
{
return [
$this->scanAction(),
$this->installAction(),
];
}
protected function scanAction(): Action
{
return Action::make('scan')
->label(__('Scan for Sites'))
->icon('heroicon-o-magnifying-glass')
->color('gray')
->modalHeading(__('Scan for WordPress Sites'))
->modalDescription(__('Search your home folder for WordPress installations not yet tracked.'))
->modalIcon('heroicon-o-magnifying-glass')
->modalIconColor('info')
->modalSubmitActionLabel(__('Scan'))
->form(function (): array {
// Perform scan when form is loaded
$result = $this->getAgent()->wpScan($this->getUsername());
$this->scannedSites = ($result['success'] ?? false) ? ($result['found'] ?? []) : [];
if (empty($this->scannedSites)) {
return [
\Filament\Forms\Components\Placeholder::make('no_sites')
->label('')
->content(__('No untracked WordPress installations found in your home folder.')),
];
}
$options = [];
foreach ($this->scannedSites as $site) {
$label = ($site['site_url'] ?? $site['path']).' (v'.($site['version'] ?? '?').')';
$options[$site['path']] = $label;
}
return [
\Filament\Forms\Components\Placeholder::make('found_info')
->label('')
->content(__('Found :count WordPress installation(s). Select which ones to import:', ['count' => count($this->scannedSites)])),
\Filament\Forms\Components\CheckboxList::make('sites_to_import')
->label(__('WordPress Sites'))
->options($options)
->default(array_keys($options))
->descriptions(collect($this->scannedSites)->mapWithKeys(fn ($site) => [$site['path'] => $site['path']])->toArray())
->bulkToggleable(),
];
})
->action(function (array $data): void {
$sitesToImport = $data['sites_to_import'] ?? [];
if (empty($sitesToImport)) {
Notification::make()
->title(__('No Sites Selected'))
->body(__('Please select at least one site to import.'))
->warning()
->send();
return;
}
$imported = 0;
$failed = 0;
foreach ($sitesToImport as $path) {
try {
$result = $this->getAgent()->wpImport($this->getUsername(), $path);
if ($result['success'] ?? false) {
$imported++;
} else {
$failed++;
}
} catch (Exception $e) {
$failed++;
}
}
if ($imported > 0) {
Notification::make()
->title(__('Import Complete'))
->body(__(':count site(s) imported successfully.', ['count' => $imported]))
->success()
->send();
}
if ($failed > 0) {
Notification::make()
->title(__('Some Imports Failed'))
->body(__(':count site(s) could not be imported.', ['count' => $failed]))
->warning()
->send();
}
$this->loadData();
$this->resetTable();
});
}
protected function installAction(): Action
{
$domainOptions = [];
foreach ($this->domains as $domain) {
$domainOptions[$domain['domain']] = $domain['domain'];
}
return Action::make('install')
->label(__('Install WordPress'))
->icon('heroicon-o-plus-circle')
->color('primary')
->modalWidth('lg')
->modalHeading(__('Install New WordPress Site'))
->modalDescription(__('Set up a new WordPress installation on one of your domains'))
->modalIcon('heroicon-o-pencil-square')
->modalIconColor('primary')
->modalSubmitActionLabel(__('Install WordPress'))
->form([
Select::make('domain')
->label(__('Domain'))
->options($domainOptions)
->required()
->searchable()
->placeholder(__('Select a domain...'))
->helperText(__('The domain where WordPress will be installed')),
Toggle::make('use_www')
->label(__('Use www prefix'))
->helperText(__('Install on www.domain.com instead of domain.com'))
->default(false),
TextInput::make('path')
->label(__('Directory (optional)'))
->placeholder(__('Leave empty to install in root'))
->helperText(__('e.g., "blog" to install at domain.com/blog')),
TextInput::make('site_title')
->label(__('Site Title'))
->required()
->default(__('My WordPress Site'))
->helperText(__('The name of your WordPress site')),
TextInput::make('admin_user')
->label(__('Admin Username'))
->required()
->default('admin')
->alphaNum()
->helperText(__('Username for the WordPress admin account')),
TextInput::make('admin_password')
->label(__('Admin Password'))
->password()
->revealable()
->required()
->default(fn () => $this->generateSecurePassword())
->minLength(8)
->rules([
'regex:/[a-z]/', // lowercase
'regex:/[A-Z]/', // uppercase
'regex:/[0-9]/', // number
])
->suffixActions([
Action::make('generatePassword')
->icon('heroicon-o-arrow-path')
->tooltip(__('Generate secure password'))
->action(fn ($set) => $set('admin_password', $this->generateSecurePassword())),
Action::make('copyPassword')
->icon('heroicon-o-clipboard-document')
->tooltip(__('Copy to clipboard'))
->action(function ($state, $livewire) {
if ($state) {
$escaped = addslashes($state);
$livewire->js("navigator.clipboard.writeText('{$escaped}')");
Notification::make()
->title(__('Copied to clipboard'))
->success()
->duration(2000)
->send();
}
}),
])
->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')),
TextInput::make('admin_email')
->label(__('Admin Email'))
->required()
->email()
->default(Auth::user()->email ?? '')
->helperText(__('Email address for the WordPress admin account')),
Select::make('language')
->label(__('Language'))
->options([
'en_US' => __('English (United States)'),
'en_GB' => __('English (UK)'),
'es_ES' => __('Español (España)'),
'es_MX' => __('Español (México)'),
'fr_FR' => __('Français'),
'de_DE' => __('Deutsch'),
'it_IT' => __('Italiano'),
'pt_BR' => __('Português (Brasil)'),
'pt_PT' => __('Português (Portugal)'),
'ru_RU' => __('Русский'),
'ja' => __('日本語'),
'zh_CN' => __('中文 (简体)'),
'zh_TW' => __('中文 (繁體)'),
'ar' => __('العربية'),
'he_IL' => __('עברית'),
'hi_IN' => __('हिन्दी'),
'ko_KR' => __('한국어'),
'nl_NL' => __('Nederlands'),
'pl_PL' => __('Polski'),
'sv_SE' => __('Svenska'),
'tr_TR' => __('Türkçe'),
'id_ID' => __('Bahasa Indonesia'),
'th' => __('ไทย'),
'vi' => __('Tiếng Việt'),
])
->default('en_US')
->searchable()
->required()
->helperText(__('Default language for WordPress admin and content')),
Toggle::make('enable_cache')
->label(__('Enable Jabali Cache'))
->helperText(__('Install Redis object caching for better performance'))
->default(true),
Toggle::make('enable_auto_update')
->label(__('Enable Auto-Updates'))
->helperText(__('Automatically update WordPress, plugins, and themes'))
->default(false),
])
->action(function (array $data): void {
try {
Notification::make()
->title(__('Installing WordPress...'))
->body(__('This may take a minute.'))
->info()
->send();
$result = $this->getAgent()->wpInstall($this->getUsername(), $data['domain'], [
'path' => $data['path'] ?? '',
'site_title' => $data['site_title'],
'admin_user' => $data['admin_user'],
'admin_password' => $data['admin_password'],
'admin_email' => $data['admin_email'],
'use_www' => $data['use_www'] ?? false,
'language' => $data['language'] ?? 'en_US',
]);
if ($result['success'] ?? false) {
$this->credentials = [
'url' => $result['url'],
'admin_url' => $result['admin_url'],
'admin_user' => $result['admin_user'],
'admin_password' => $result['admin_password'],
'db_name' => $result['db_name'],
'db_user' => $result['db_user'],
'db_password' => $result['db_password'],
];
// Enable Jabali Cache (Redis object cache) if requested
// Note: nginx page cache is enabled by default for all WordPress sites
if ($data['enable_cache'] ?? false) {
$siteId = $result['site_id'] ?? null;
if ($siteId) {
try {
$this->getAgent()->wpCacheEnable($this->getUsername(), $siteId);
$this->credentials['cache_enabled'] = true;
} catch (Exception $e) {
// Cache enable failed, but installation succeeded
$this->credentials['cache_enabled'] = false;
$this->credentials['cache_error'] = $e->getMessage();
}
}
}
// Store MySQL credentials for phpMyAdmin SSO
if (! empty($result['db_user']) && ! empty($result['db_password'])) {
MysqlCredential::updateOrCreate(
[
'user_id' => Auth::id(),
'mysql_username' => $result['db_user'],
],
[
'mysql_password_encrypted' => Crypt::encryptString($result['db_password']),
]
);
}
Notification::make()
->title(__('WordPress Installed!'))
->success()
->send();
$this->loadData();
$this->resetTable();
// Show credentials modal
$this->mountAction('showCredentialsAction');
} else {
throw new Exception($result['error'] ?? __('Unknown error'));
}
} catch (Exception $e) {
Notification::make()
->title(__('Installation Failed'))
->body($e->getMessage())
->danger()
->send();
}
});
}
public function autoLogin(string $siteId): void
{
try {
$result = $this->getAgent()->wpAutoLogin($this->getUsername(), $siteId);
if ($result['success'] ?? false) {
$loginUrl = $result['login_url'];
$this->js("window.open('{$loginUrl}', '_blank')");
} else {
throw new Exception($result['error'] ?? __('Failed to generate login link'));
}
} catch (Exception $e) {
Notification::make()
->title(__('Auto-login Failed'))
->body($e->getMessage())
->danger()
->send();
}
}
public function deleteSite(string $siteId): void
{
$this->selectedSiteId = $siteId;
$this->mountAction('deleteAction');
}
public function deleteAction(): Action
{
return Action::make('deleteAction')
->requiresConfirmation()
->modalHeading(__('Delete WordPress Site'))
->modalDescription(__('Are you sure you want to delete this WordPress installation? This action cannot be undone.'))
->modalIcon('heroicon-o-trash')
->modalIconColor('danger')
->modalSubmitActionLabel(__('Delete WordPress Site'))
->form([
Toggle::make('delete_files')
->label(__('Delete all files'))
->default(true)
->helperText(__('Permanently remove all WordPress files from the server')),
Toggle::make('delete_database')
->label(__('Delete database'))
->default(true)
->helperText(__('Permanently delete the WordPress database and all content')),
])
->color('danger')
->action(function (array $data): void {
try {
$result = $this->getAgent()->wpDelete(
$this->getUsername(),
$this->selectedSiteId,
$data['delete_files'] ?? true,
$data['delete_database'] ?? true
);
if ($result['success'] ?? false) {
Notification::make()
->title(__('WordPress Deleted'))
->success()
->send();
$this->loadData();
} else {
throw new Exception($result['error'] ?? __('Deletion failed'));
}
} catch (Exception $e) {
Notification::make()
->title(__('Deletion Failed'))
->body($e->getMessage())
->danger()
->send();
}
});
}
public function toggleCache(string $siteId, bool $removePlugin = false, bool $resetData = false): void
{
try {
// Get site info to get domain
$site = collect($this->sites)->firstWhere('id', $siteId);
if (! $site) {
throw new Exception(__('Site not found'));
}
$siteDomain = $site['domain'] ?? '';
// Get current cache status (Redis object cache)
$statusResult = $this->getAgent()->wpCacheStatus($this->getUsername(), $siteId);
$isEnabled = ($statusResult['status']['enabled'] ?? false);
if ($isEnabled) {
// Disable Redis object cache and nginx page cache
$result = $this->getAgent()->wpCacheDisable($this->getUsername(), $siteId, $removePlugin, $resetData);
if ($result['success'] ?? false) {
// Also update Domain model's page_cache_enabled field
if ($siteDomain) {
Domain::where('domain', $siteDomain)
->where('user_id', Auth::id())
->update(['page_cache_enabled' => false]);
}
$message = match (true) {
$removePlugin && $resetData => __('Jabali Cache has been completely uninstalled and all settings removed.'),
$removePlugin => __('Jabali Cache has been disabled and uninstalled from this site.'),
default => __('Jabali Cache has been disabled for this site.'),
};
Notification::make()
->title(__('Cache Disabled'))
->body($message)
->success()
->send();
} else {
throw new Exception($result['error'] ?? __('Failed to disable cache'));
}
} else {
// Enable Redis object cache and nginx page cache
$result = $this->getAgent()->wpCacheEnable($this->getUsername(), $siteId);
if ($result['success'] ?? false) {
// Also update Domain model's page_cache_enabled field
if ($siteDomain) {
Domain::where('domain', $siteDomain)
->where('user_id', Auth::id())
->update(['page_cache_enabled' => true]);
}
Notification::make()
->title(__('Cache Enabled'))
->body(__('Jabali Cache has been enabled. Cache prefix: :prefix', ['prefix' => $result['cache_prefix'] ?? __('unknown')]))
->success()
->send();
} else {
// Check if there are conflicting plugins
if (isset($result['conflicts']) && ! empty($result['conflicts'])) {
$conflictNames = array_column($result['conflicts'], 'name');
Notification::make()
->title(__('Conflicting Plugins Detected'))
->body(__('Please disable these caching plugins first: :plugins', ['plugins' => implode(', ', $conflictNames)]))
->warning()
->persistent()
->send();
} else {
throw new Exception($result['error'] ?? __('Failed to enable cache'));
}
}
}
$this->loadData();
$this->resetTable();
} catch (Exception $e) {
Notification::make()
->title(__('Cache Toggle Failed'))
->body($e->getMessage())
->danger()
->send();
}
}
public function toggleDebug(string $siteId): void
{
try {
$result = $this->getAgent()->send('wp.toggle_debug', [
'username' => $this->getUsername(),
'site_id' => $siteId,
]);
if ($result['success'] ?? false) {
$enabled = $result['debug_enabled'] ?? false;
Notification::make()
->title($enabled ? __('Debug Mode Enabled') : __('Debug Mode Disabled'))
->body($enabled
? __('WP_DEBUG is now ON. Check wp-content/debug.log for errors.')
: __('WP_DEBUG has been turned OFF.'))
->color($enabled ? 'warning' : 'success')
->send();
$this->loadData();
$this->resetTable();
} else {
throw new Exception($result['error'] ?? __('Failed to toggle debug mode'));
}
} catch (Exception $e) {
Notification::make()
->title(__('Debug Toggle Failed'))
->body($e->getMessage())
->danger()
->send();
}
}
public function toggleAutoUpdate(string $siteId): void
{
try {
$result = $this->getAgent()->send('wp.toggle_auto_update', [
'username' => $this->getUsername(),
'site_id' => $siteId,
]);
if ($result['success'] ?? false) {
$enabled = $result['auto_update'] ?? false;
Notification::make()
->title($enabled ? __('Auto-Update Enabled') : __('Auto-Update Disabled'))
->body($enabled
? __('WordPress core, plugins, and themes will be updated automatically.')
: __('Automatic updates have been disabled.'))
->success()
->send();
$this->loadData();
$this->resetTable();
} else {
throw new Exception($result['error'] ?? __('Failed to toggle auto-update'));
}
} catch (Exception $e) {
Notification::make()
->title(__('Auto-Update Toggle Failed'))
->body($e->getMessage())
->danger()
->send();
}
}
public function updateWordPress(string $siteId): void
{
try {
Notification::make()
->title(__('Updating WordPress...'))
->body(__('This may take a moment.'))
->info()
->send();
$result = $this->getAgent()->send('wp.update', [
'username' => $this->getUsername(),
'site_id' => $siteId,
'type' => 'all',
]);
if ($result['success'] ?? false) {
Notification::make()
->title(__('WordPress Updated'))
->body(__('Core, plugins, and themes have been updated.'))
->success()
->send();
$this->loadData();
$this->resetTable();
} else {
throw new Exception($result['error'] ?? __('Update failed'));
}
} catch (Exception $e) {
Notification::make()
->title(__('Update Failed'))
->body($e->getMessage())
->danger()
->send();
}
}
public function createStaging(string $siteId, string $subdomain): void
{
try {
Notification::make()
->title(__('Creating Staging Environment...'))
->body(__('This may take several minutes.'))
->info()
->send();
$result = $this->getAgent()->send('wp.create_staging', [
'username' => $this->getUsername(),
'site_id' => $siteId,
'subdomain' => 'staging-'.$subdomain,
]);
if ($result['success'] ?? false) {
Notification::make()
->title(__('Staging Environment Created'))
->body(__('Your staging site is available at: :url', ['url' => $result['staging_url'] ?? '']))
->success()
->persistent()
->send();
$this->loadData();
$this->resetTable();
} else {
throw new Exception($result['error'] ?? __('Failed to create staging environment'));
}
} catch (Exception $e) {
Notification::make()
->title(__('Staging Creation Failed'))
->body($e->getMessage())
->danger()
->send();
}
}
public function pushStaging(string $stagingSiteId): void
{
try {
Notification::make()
->title(__('Pushing staging to production...'))
->body(__('This may take several minutes.'))
->info()
->send();
$result = $this->getAgent()->wpPushStaging($this->getUsername(), $stagingSiteId);
if ($result['success'] ?? false) {
Notification::make()
->title(__('Staging pushed to production'))
->success()
->send();
$this->loadData();
$this->resetTable();
} else {
throw new Exception($result['error'] ?? __('Failed to push staging site'));
}
} catch (Exception $e) {
Notification::make()
->title(__('Push Failed'))
->body($e->getMessage())
->danger()
->send();
}
}
public function flushCache(string $siteId): void
{
try {
$result = $this->getAgent()->wpCacheFlush($this->getUsername(), $siteId);
if ($result['success'] ?? false) {
$keysDeleted = $result['keys_deleted'] ?? null;
if ($keysDeleted !== null) {
$body = __('Object cache has been flushed. (:count keys deleted)', ['count' => $keysDeleted]);
} else {
$body = __('Object cache has been flushed.');
}
Notification::make()
->title(__('Cache Flushed'))
->body($body)
->success()
->send();
} else {
throw new Exception($result['error'] ?? __('Failed to flush cache'));
}
} catch (Exception $e) {
Notification::make()
->title(__('Flush Failed'))
->body($e->getMessage())
->danger()
->send();
}
}
public function getCacheStatus(string $siteId): array
{
try {
$result = $this->getAgent()->wpCacheStatus($this->getUsername(), $siteId);
if ($result['success'] ?? false) {
return $result['status'];
}
} catch (Exception $e) {
// Ignore errors, return empty status
}
return [
'enabled' => false,
'drop_in_installed' => false,
'plugin_installed' => false,
'redis_connected' => false,
'cached_keys' => 0,
];
}
public function runSecurityScan(string $siteId): void
{
// Find the site
$site = collect($this->sites)->firstWhere('id', $siteId);
if (! $site) {
Notification::make()
->title(__('Site not found'))
->danger()
->send();
return;
}
// Check if WPScan is installed
exec('which wpscan 2>/dev/null', $output, $code);
if ($code !== 0) {
Notification::make()
->title(__('WPScan not available'))
->body(__('Please contact your administrator to enable security scanning.'))
->warning()
->send();
return;
}
$this->isSecurityScanning = true;
$this->scanningSiteId = $siteId;
$this->scanningSiteUrl = $site['url'];
$this->securityScanResults = [];
Notification::make()
->title(__('Starting security scan...'))
->body(__('Scanning :url for vulnerabilities.', ['url' => $site['url']]))
->info()
->send();
// Run WPScan
$url = $site['url'];
exec('wpscan --url '.escapeshellarg($url).' --format json --no-banner 2>&1', $scanOutput, $scanCode);
$jsonOutput = implode("\n", $scanOutput);
$results = json_decode($jsonOutput, true);
if (! $results) {
$this->securityScanResults = [
'error' => __('Failed to parse scan results'),
'raw_output' => $jsonOutput,
'url' => $url,
'scan_time' => now()->format('Y-m-d H:i:s'),
];
} else {
$results['url'] = $url;
$results['scan_time'] = now()->format('Y-m-d H:i:s');
$this->securityScanResults = $this->parseWpScanResults($results);
}
$this->isSecurityScanning = false;
$this->showSecurityScanModal = true;
$vulnCount = count($this->securityScanResults['vulnerabilities'] ?? []);
if ($vulnCount > 0) {
Notification::make()
->title(__('Security scan completed'))
->body(__('Found :count potential vulnerability(ies)', ['count' => $vulnCount]))
->warning()
->send();
} else {
Notification::make()
->title(__('Security scan completed'))
->body(__('No vulnerabilities found!'))
->success()
->send();
}
}
protected function parseWpScanResults(array $results): array
{
$parsed = [
'url' => $results['url'] ?? '',
'scan_time' => $results['scan_time'] ?? now()->format('Y-m-d H:i:s'),
'wordpress_version' => null,
'main_theme' => null,
'plugins' => [],
'vulnerabilities' => [],
'interesting_findings' => [],
];
// WordPress version
if (isset($results['version']['number'])) {
$parsed['wordpress_version'] = [
'number' => $results['version']['number'],
'status' => $results['version']['status'] ?? __('unknown'),
'vulnerabilities' => [],
];
if (! empty($results['version']['vulnerabilities'])) {
foreach ($results['version']['vulnerabilities'] as $vuln) {
$parsed['vulnerabilities'][] = [
'type' => __('WordPress Core'),
'title' => $vuln['title'] ?? __('Unknown vulnerability'),
'references' => $vuln['references'] ?? [],
'fixed_in' => $vuln['fixed_in'] ?? null,
];
}
}
}
// Main theme
if (isset($results['main_theme']['slug'])) {
$parsed['main_theme'] = [
'name' => $results['main_theme']['slug'],
'version' => $results['main_theme']['version']['number'] ?? __('Unknown'),
];
if (! empty($results['main_theme']['vulnerabilities'])) {
foreach ($results['main_theme']['vulnerabilities'] as $vuln) {
$parsed['vulnerabilities'][] = [
'type' => __('Theme: :name', ['name' => $results['main_theme']['slug']]),
'title' => $vuln['title'] ?? __('Unknown vulnerability'),
'references' => $vuln['references'] ?? [],
'fixed_in' => $vuln['fixed_in'] ?? null,
];
}
}
}
// Plugins
if (! empty($results['plugins'])) {
foreach ($results['plugins'] as $slug => $plugin) {
$parsed['plugins'][] = [
'name' => $slug,
'version' => $plugin['version']['number'] ?? __('Unknown'),
];
if (! empty($plugin['vulnerabilities'])) {
foreach ($plugin['vulnerabilities'] as $vuln) {
$parsed['vulnerabilities'][] = [
'type' => __('Plugin: :name', ['name' => $slug]),
'title' => $vuln['title'] ?? __('Unknown vulnerability'),
'references' => $vuln['references'] ?? [],
'fixed_in' => $vuln['fixed_in'] ?? null,
];
}
}
}
}
// Interesting findings
if (! empty($results['interesting_findings'])) {
foreach ($results['interesting_findings'] as $finding) {
$parsed['interesting_findings'][] = [
'type' => $finding['type'] ?? 'info',
'description' => $finding['to_s'] ?? ($finding['url'] ?? __('Unknown finding')),
'url' => $finding['url'] ?? null,
];
}
}
return $parsed;
}
public function closeSecurityScanModal(): void
{
$this->showSecurityScanModal = false;
$this->securityScanResults = [];
$this->scanningSiteId = null;
$this->scanningSiteUrl = null;
}
public function showCredentialsModal(): void
{
$this->mountAction('showCredentialsAction');
}
public function showCredentialsAction(): Action
{
return Action::make('showCredentialsAction')
->modalHeading(__('WordPress Installed!'))
->modalDescription(__('Save these credentials! They won\'t be shown again.'))
->modalIcon('heroicon-o-check-circle')
->modalIconColor('success')
->modalSubmitAction(false)
->modalCancelActionLabel(__('Done'))
->infolist([
Section::make(__('Site Information'))
->icon('heroicon-o-globe-alt')
->columns(1)
->schema([
TextEntry::make('site_url')
->label(__('Site URL'))
->state(fn () => $this->credentials['url'] ?? '')
->copyable()
->fontFamily('mono'),
TextEntry::make('admin_url')
->label(__('Admin URL'))
->state(fn () => $this->credentials['admin_url'] ?? '')
->copyable()
->fontFamily('mono'),
]),
Section::make(__('Admin Credentials'))
->icon('heroicon-o-user')
->columns(2)
->schema([
TextEntry::make('admin_user')
->label(__('Username'))
->state(fn () => $this->credentials['admin_user'] ?? '')
->copyable()
->fontFamily('mono'),
TextEntry::make('admin_password')
->label(__('Password'))
->state(fn () => $this->credentials['admin_password'] ?? '')
->copyable()
->fontFamily('mono'),
]),
Section::make(__('Database Credentials'))
->icon('heroicon-o-circle-stack')
->columns(1)
->schema([
TextEntry::make('db_name')
->label(__('Database Name'))
->state(fn () => $this->credentials['db_name'] ?? '')
->copyable()
->fontFamily('mono'),
TextEntry::make('db_user')
->label(__('Database User'))
->state(fn () => $this->credentials['db_user'] ?? '')
->copyable()
->fontFamily('mono'),
TextEntry::make('db_password')
->label(__('Database Password'))
->state(fn () => $this->credentials['db_password'] ?? '')
->copyable()
->fontFamily('mono'),
]),
]);
}
public function showScanResultsModal(): void
{
$this->mountAction('showScanResultsAction');
}
public function showScanResultsAction(): Action
{
return Action::make('showScanResultsAction')
->modalHeading(__('Found WordPress Sites'))
->modalDescription(__('The following WordPress installations were found in your home folder and are not yet tracked. Click "Import" to add them to your dashboard.'))
->modalIcon('heroicon-o-magnifying-glass')
->modalIconColor('info')
->modalSubmitAction(false)
->modalCancelActionLabel(__('Close'))
->infolist([
Section::make(__('Discovered Sites'))
->schema(
collect($this->scannedSites)->map(fn ($site, $index) => TextEntry::make("site_{$index}")
->label($site['site_url'] ?? __('WordPress Site'))
->state($site['path'])
->helperText(isset($site['version']) ? 'v'.$site['version'] : '')
)->toArray()
),
]);
}
public function showSecurityScanResultsModal(): void
{
$this->mountAction('showSecurityScanResultsAction');
}
public function showSecurityScanResultsAction(): Action
{
$results = $this->securityScanResults;
$vulnCount = count($results['vulnerabilities'] ?? []);
return Action::make('showSecurityScanResultsAction')
->modalHeading(__('Security Scan Results'))
->modalDescription($results['url'] ?? '')
->modalIcon('heroicon-o-shield-check')
->modalIconColor($vulnCount > 0 ? 'danger' : 'success')
->modalSubmitAction(false)
->modalCancelActionLabel(__('Close'))
->infolist(function () use ($results, $vulnCount): array {
$schema = [];
// Scan info
$schema[] = Section::make(__('Scan Information'))
->icon('heroicon-o-information-circle')
->columns(2)
->schema([
TextEntry::make('scanned_url')
->label(__('Scanned URL'))
->state($results['url'] ?? __('Unknown')),
TextEntry::make('scan_time')
->label(__('Scan Time'))
->state($results['scan_time'] ?? ''),
]);
// WordPress version
if (isset($results['wordpress_version'])) {
$schema[] = Section::make(__('WordPress Version'))
->icon('heroicon-o-code-bracket')
->schema([
TextEntry::make('wp_version')
->label(__('Version'))
->state($results['wordpress_version']['number'])
->badge()
->color(match ($results['wordpress_version']['status'] ?? '') {
'insecure' => 'danger',
'outdated' => 'warning',
default => 'success',
}),
]);
}
// Vulnerabilities
if ($vulnCount > 0) {
$vulnEntries = [];
foreach ($results['vulnerabilities'] as $index => $vuln) {
$vulnEntries[] = TextEntry::make("vuln_{$index}")
->label($vuln['type'])
->state($vuln['title'])
->helperText($vuln['fixed_in'] ? __('Fixed in: :version', ['version' => $vuln['fixed_in']]) : '')
->color('danger');
}
$schema[] = Section::make(__('Vulnerabilities Found')." ({$vulnCount})")
->icon('heroicon-o-exclamation-triangle')
->iconColor('danger')
->schema($vulnEntries);
} else {
$schema[] = Section::make(__('No Vulnerabilities Found'))
->icon('heroicon-o-check-circle')
->iconColor('success')
->description(__('Your WordPress site appears to be secure'));
}
// Interesting findings
if (! empty($results['interesting_findings'])) {
$findingEntries = [];
foreach (array_slice($results['interesting_findings'], 0, 10) as $index => $finding) {
$findingEntries[] = TextEntry::make("finding_{$index}")
->hiddenLabel()
->state($finding['description']);
}
$schema[] = Section::make(__('Interesting Findings'))
->icon('heroicon-o-eye')
->collapsed()
->schema($findingEntries);
}
// Detected plugins
if (! empty($results['plugins'])) {
$pluginEntries = [];
foreach ($results['plugins'] as $index => $plugin) {
$pluginEntries[] = TextEntry::make("plugin_{$index}")
->hiddenLabel()
->state($plugin['name'].' v'.$plugin['version'])
->badge()
->color('gray');
}
$schema[] = Section::make(__('Detected Plugins').' ('.count($results['plugins']).')')
->icon('heroicon-o-puzzle-piece')
->collapsed()
->schema($pluginEntries);
}
return $schema;
});
}
public function captureScreenshot(string $siteId): void
{
$site = collect($this->sites)->firstWhere('id', $siteId);
if (! $site) {
Notification::make()
->title(__('Site not found'))
->danger()
->send();
return;
}
$url = $site['url'];
try {
// Ensure screenshots directory exists
$screenshotDir = storage_path('app/public/screenshots');
if (! is_dir($screenshotDir)) {
mkdir($screenshotDir, 0755, true);
}
$filename = 'wp_'.$siteId.'.png';
$filepath = $screenshotDir.'/'.$filename;
// Use screenshot wrapper script that handles Chromium crashpad issues
$screenshotBin = base_path('bin/screenshot');
if (! file_exists($screenshotBin) || ! is_executable($screenshotBin)) {
Notification::make()
->title(__('Screenshot failed'))
->body(__('Screenshot script not found.'))
->warning()
->send();
return;
}
$cmd = sprintf('%s %s %s 2>&1',
escapeshellarg($screenshotBin),
escapeshellarg($url),
escapeshellarg($filepath)
);
exec($cmd, $output, $code);
if (file_exists($filepath) && filesize($filepath) > 1000) {
touch($filepath);
Notification::make()
->title(__('Screenshot captured'))
->body(__('Website screenshot has been updated.'))
->success()
->send();
} else {
Notification::make()
->title(__('Screenshot failed'))
->body(__('Could not capture screenshot. Error: ').implode("\n", $output))
->warning()
->send();
}
} catch (Exception $e) {
Notification::make()
->title(__('Screenshot failed'))
->body($e->getMessage())
->danger()
->send();
}
}
public function getScreenshotUrl(string $siteId): ?string
{
$filename = 'wp_'.$siteId.'.png';
$filepath = storage_path('app/public/screenshots/'.$filename);
if (file_exists($filepath)) {
// Add timestamp to bust cache
return asset('storage/screenshots/'.$filename).'?t='.filemtime($filepath);
}
return null;
}
public function hasScreenshot(string $siteId): bool
{
$filename = 'wp_'.$siteId.'.png';
return file_exists(storage_path('app/public/screenshots/'.$filename));
}
}