diff --git a/app/Filament/Admin/Pages/Dashboard.php b/app/Filament/Admin/Pages/Dashboard.php index 2136bc5..49eb1c2 100644 --- a/app/Filament/Admin/Pages/Dashboard.php +++ b/app/Filament/Admin/Pages/Dashboard.php @@ -14,6 +14,7 @@ use Filament\Actions\Contracts\HasActions; use Filament\Forms\Components\TextInput; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; +use Filament\Notifications\Notification; use Filament\Pages\Page; use Filament\Schemas\Components\EmbeddedTable; use Filament\Schemas\Components\Grid; @@ -94,6 +95,14 @@ class Dashboard extends Page implements HasActions, HasForms ->modalHeading(__('Welcome to Jabali!')) ->modalDescription(__('Let\'s get your server control panel set up.')) ->modalWidth('2xl') + ->fillForm(function (): array { + $savedRecipients = trim((string) DnsSetting::get('admin_email_recipients', '')); + $savedPrimaryEmail = $savedRecipients === '' ? '' : trim(explode(',', $savedRecipients)[0]); + + return [ + 'admin_email' => $savedPrimaryEmail, + ]; + }) ->form([ Section::make(__('Next Steps')) ->description(__('Here is a quick setup path to get your first site online.')) @@ -143,13 +152,22 @@ class Dashboard extends Page implements HasActions, HasForms ->email() ->placeholder(__('admin@example.com')), ]) - ->modalSubmitActionLabel(__("Don't show again")) + ->modalSubmitActionLabel(__('Save and close')) ->action(function (array $data): void { - if (! empty($data['admin_email'])) { - DnsSetting::set('admin_email_recipients', $data['admin_email']); + $adminEmail = trim((string) ($data['admin_email'] ?? '')); + + if ($adminEmail !== '') { + DnsSetting::set('admin_email_recipients', $adminEmail); } + DnsSetting::set('onboarding_completed', '1'); DnsSetting::clearCache(); + + Notification::make() + ->title(__('Setup saved')) + ->body(__('Your notification email has been updated.')) + ->success() + ->send(); }), ]; } diff --git a/app/Filament/Jabali/Pages/WordPress.php b/app/Filament/Jabali/Pages/WordPress.php index 848ce20..eeeb6ac 100644 --- a/app/Filament/Jabali/Pages/WordPress.php +++ b/app/Filament/Jabali/Pages/WordPress.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Filament\Jabali\Pages; use App\Models\Domain; +use App\Models\DnsRecord; +use App\Models\DnsSetting; use App\Models\MysqlCredential; use App\Services\Agent\AgentClient; use BackedEnum; @@ -205,16 +207,46 @@ class WordPress extends Page implements HasActions, HasForms, HasTable ->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'])), + ->form(function (array $record): array { + $sourceDomain = strtolower(trim((string) ($record['domain'] ?? ''))); + $ownedDomainOptions = $this->getOwnedDomainOptions([$sourceDomain]); + + return [ + Select::make('staging_target_type') + ->label(__('Target Type')) + ->options([ + 'subdomain' => __('Subdomain (on source domain)'), + 'domain' => __('Existing domain from my list'), + ]) + ->default('subdomain') + ->required() + ->native(false) + ->live(), + TextInput::make('staging_subdomain') + ->label(__('Subdomain')) + ->suffix(fn (array $record): string => '.'.($record['domain'] ?? '')) + ->default('test') + ->required(fn (Get $get): bool => $get('staging_target_type') !== 'domain') + ->visible(fn (Get $get): bool => $get('staging_target_type') !== 'domain') + ->regex('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/') + ->helperText(__('Example: "test" creates test.:domain', ['domain' => $record['domain'] ?? ''])), + Select::make('staging_domain') + ->label(__('Target Domain')) + ->options($ownedDomainOptions) + ->required(fn (Get $get): bool => $get('staging_target_type') === 'domain') + ->visible(fn (Get $get): bool => $get('staging_target_type') === 'domain') + ->searchable() + ->native(false) + ->placeholder(__('Select a domain...')) + ->helperText(__('Use one of your existing domains as the staging target.')), + ]; + }) + ->action(fn (array $data, array $record) => $this->createStaging( + $record['id'], + (string) ($data['staging_subdomain'] ?? ''), + (string) ($data['staging_domain'] ?? ''), + (string) ($data['staging_target_type'] ?? 'subdomain') + )), Action::make('pushStaging') ->label(__('Push to Production')) ->icon('heroicon-o-arrow-up-tray') @@ -259,6 +291,17 @@ class WordPress extends Page implements HasActions, HasForms, HasTable ); if ($result['success'] ?? false) { + if (($record['is_staging'] ?? false)) { + $this->removeStagingDnsRecords($record); + } + + if (($record['is_staging'] ?? false) && ! empty($record['domain'])) { + Domain::query() + ->where('user_id', Auth::id()) + ->where('domain', (string) $record['domain']) + ->delete(); + } + // Delete screenshot if exists $screenshotPath = storage_path('app/public/screenshots/wp-'.$record['id'].'.png'); if (file_exists($screenshotPath)) { @@ -894,22 +937,75 @@ class WordPress extends Page implements HasActions, HasForms, HasTable } } - public function createStaging(string $siteId, string $subdomain): void + public function createStaging(string $siteId, string $subdomain, string $targetDomain = '', string $targetType = 'subdomain'): void { try { + $sourceSite = collect($this->sites)->firstWhere('id', $siteId); + $sourceDomain = (string) ($sourceSite['domain'] ?? ''); + $normalizedSourceDomain = strtolower(trim($sourceDomain)); + + $agentPayload = [ + 'username' => $this->getUsername(), + 'site_id' => $siteId, + ]; + + if ($targetType === 'domain') { + $targetDomain = strtolower(trim($targetDomain)); + if ($targetDomain === '') { + throw new Exception(__('Please choose a target domain.')); + } + if ($targetDomain === $normalizedSourceDomain) { + throw new Exception(__('The staging domain must be different from the source domain.')); + } + if (! $this->isOwnedDomain($targetDomain)) { + throw new Exception(__('The selected domain is not in your domain list.')); + } + $agentPayload['target_domain'] = $targetDomain; + } else { + $subdomain = strtolower(trim($subdomain)); + if ($subdomain === '') { + throw new Exception(__('Please enter a subdomain.')); + } + if (! preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $subdomain)) { + throw new Exception(__('Subdomain can contain only letters, numbers, and hyphens.')); + } + $agentPayload['subdomain'] = $subdomain; + } + 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, - ]); + $result = $this->getAgent()->send('wp.create_staging', $agentPayload); if ($result['success'] ?? false) { + $stagingDomain = (string) ($result['staging_domain'] ?? ''); + if ($stagingDomain !== '') { + Domain::firstOrCreate( + [ + 'user_id' => Auth::id(), + 'domain' => $stagingDomain, + ], + [ + 'document_root' => '/home/'.$this->getUsername().'/domains/'.$stagingDomain.'/public_html', + 'is_active' => true, + 'ssl_enabled' => false, + 'directory_index' => 'index.php index.html', + 'page_cache_enabled' => false, + ] + ); + } + + if ( + $sourceDomain !== '' + && $stagingDomain !== '' + && str_ends_with(strtolower($stagingDomain), '.'.strtolower($sourceDomain)) + ) { + $this->ensureStagingDnsRecords($sourceDomain, $stagingDomain); + } + Notification::make() ->title(__('Staging Environment Created')) ->body(__('Your staging site is available at: :url', ['url' => $result['staging_url'] ?? ''])) @@ -1466,4 +1562,193 @@ class WordPress extends Page implements HasActions, HasForms, HasTable return file_exists(storage_path('app/public/screenshots/'.$filename)); } + + protected function ensureStagingDnsRecords(string $sourceDomainName, string $stagingDomainName): void + { + $sourceDomain = Domain::query() + ->where('user_id', Auth::id()) + ->where('domain', $sourceDomainName) + ->first(); + + if (! $sourceDomain) { + return; + } + + $label = $this->extractSubdomainLabel($stagingDomainName, $sourceDomainName); + if ($label === null || $label === '') { + return; + } + + $settings = DnsSetting::getAll(); + $defaultTtl = (int) ($settings['default_ttl'] ?? 3600); + + $defaultIpv4 = $sourceDomain->ip_address + ?: ($settings['default_ip'] ?? trim((string) (shell_exec("hostname -I | awk '{print $1}'") ?? '')) ?: '127.0.0.1'); + + DnsRecord::query()->updateOrCreate( + [ + 'domain_id' => $sourceDomain->id, + 'name' => $label, + 'type' => 'A', + ], + [ + 'content' => $defaultIpv4, + 'ttl' => $defaultTtl, + 'priority' => null, + ] + ); + + $defaultIpv6 = $sourceDomain->ipv6_address ?: ($settings['default_ipv6'] ?? null); + if (! empty($defaultIpv6)) { + DnsRecord::query()->updateOrCreate( + [ + 'domain_id' => $sourceDomain->id, + 'name' => $label, + 'type' => 'AAAA', + ], + [ + 'content' => $defaultIpv6, + 'ttl' => $defaultTtl, + 'priority' => null, + ] + ); + } + + try { + $this->syncDnsZone($sourceDomain, $settings); + } catch (Exception) { + // Keep staging creation successful even if DNS sync is temporarily unavailable. + } + } + + protected function removeStagingDnsRecords(array $stagingSite): void + { + $stagingDomainName = strtolower(trim((string) ($stagingSite['domain'] ?? ''))); + if ($stagingDomainName === '') { + return; + } + + $sourceDomainName = ''; + $sourceSiteId = $stagingSite['source_site_id'] ?? null; + if (is_string($sourceSiteId) && $sourceSiteId !== '') { + $sourceSite = collect($this->sites)->firstWhere('id', $sourceSiteId); + $sourceDomainName = strtolower(trim((string) ($sourceSite['domain'] ?? ''))); + } + + if ($sourceDomainName === '') { + $parts = explode('.', $stagingDomainName, 2); + if (count($parts) === 2) { + $sourceDomainName = $parts[1]; + } + } + + if ($sourceDomainName === '') { + return; + } + + $sourceDomain = Domain::query() + ->where('user_id', Auth::id()) + ->where('domain', $sourceDomainName) + ->first(); + + if (! $sourceDomain) { + return; + } + + $label = $this->extractSubdomainLabel($stagingDomainName, $sourceDomainName); + if ($label === null || $label === '') { + return; + } + + DnsRecord::query() + ->where('domain_id', $sourceDomain->id) + ->where('name', $label) + ->whereIn('type', ['A', 'AAAA']) + ->delete(); + + try { + $this->syncDnsZone($sourceDomain); + } catch (Exception) { + // Keep deletion successful even if DNS sync is temporarily unavailable. + } + } + + protected function syncDnsZone(Domain $domain, ?array $settings = null): void + { + $settings ??= DnsSetting::getAll(); + $records = DnsRecord::query()->where('domain_id', $domain->id)->get()->toArray(); + $hostname = gethostname() ?: 'localhost'; + $serverIp = trim((string) (shell_exec("hostname -I | awk '{print $1}'") ?? '')); + + $this->getAgent()->dnsSyncZone($domain->domain, $records, [ + 'ns1' => $settings['ns1'] ?? "ns1.{$hostname}", + 'ns2' => $settings['ns2'] ?? "ns2.{$hostname}", + 'admin_email' => $settings['admin_email'] ?? "admin.{$hostname}", + 'default_ip' => $settings['default_ip'] ?? ($serverIp !== '' ? $serverIp : '127.0.0.1'), + 'default_ipv6' => $settings['default_ipv6'] ?? null, + 'default_ttl' => $settings['default_ttl'] ?? 3600, + ]); + } + + protected function extractSubdomainLabel(string $fullDomain, string $baseDomain): ?string + { + $fullDomain = strtolower(trim($fullDomain, " \t\n\r\0\x0B.")); + $baseDomain = strtolower(trim($baseDomain, " \t\n\r\0\x0B.")); + + if ($fullDomain === '' || $baseDomain === '') { + return null; + } + + if ($fullDomain === $baseDomain) { + return null; + } + + $suffix = '.'.$baseDomain; + if (! str_ends_with($fullDomain, $suffix)) { + return null; + } + + $label = substr($fullDomain, 0, -strlen($suffix)); + + return trim((string) $label, '.'); + } + + protected function getOwnedDomainOptions(array $exclude = []): array + { + $excludeSet = []; + foreach ($exclude as $value) { + $normalized = strtolower(trim((string) $value)); + if ($normalized !== '') { + $excludeSet[$normalized] = true; + } + } + + $options = []; + foreach ($this->domains as $domain) { + $name = strtolower(trim((string) ($domain['domain'] ?? ''))); + if ($name === '' || isset($excludeSet[$name])) { + continue; + } + $options[$name] = $name; + } + + return $options; + } + + protected function isOwnedDomain(string $domain): bool + { + $domain = strtolower(trim($domain)); + if ($domain === '') { + return false; + } + + foreach ($this->domains as $ownedDomain) { + $candidate = strtolower(trim((string) ($ownedDomain['domain'] ?? ''))); + if ($candidate === $domain) { + return true; + } + } + + return false; + } } diff --git a/app/Services/Agent/AgentClient.php b/app/Services/Agent/AgentClient.php index 7df7bd1..3bfdc57 100644 --- a/app/Services/Agent/AgentClient.php +++ b/app/Services/Agent/AgentClient.php @@ -504,13 +504,19 @@ class AgentClient return $this->send('wp.import', $params); } - public function wpCreateStaging(string $username, string $siteId, string $subdomain): array + public function wpCreateStaging(string $username, string $siteId, string $subdomain = 'staging', ?string $targetDomain = null): array { - return $this->send('wp.create_staging', [ + $params = [ 'username' => $username, 'site_id' => $siteId, 'subdomain' => $subdomain, - ]); + ]; + + if ($targetDomain !== null && $targetDomain !== '') { + $params['target_domain'] = $targetDomain; + } + + return $this->send('wp.create_staging', $params); } public function wpPushStaging(string $username, string $stagingSiteId): array diff --git a/bin/jabali-agent b/bin/jabali-agent index fa56f14..dc80d78 100755 --- a/bin/jabali-agent +++ b/bin/jabali-agent @@ -7914,6 +7914,8 @@ function wpDelete(array $params): array } $site = $wpSites[$siteId]; + $isStagingSite = (bool) ($site['is_staging'] ?? false); + $siteDomain = strtolower(trim((string) ($site['domain'] ?? ''))); // Delete database if requested $dbDeleted = false; @@ -7943,8 +7945,37 @@ function wpDelete(array $params): array } } - // Delete files if requested - if ($deleteFiles && !empty($site['install_path'])) { + if ($isStagingSite && $siteDomain !== '') { + $vhostFile = "/etc/nginx/sites-available/{$siteDomain}.conf"; + + if (file_exists("/etc/nginx/sites-enabled/{$siteDomain}.conf")) { + @unlink("/etc/nginx/sites-enabled/{$siteDomain}.conf"); + } + if (file_exists($vhostFile)) { + @unlink($vhostFile); + } + + if ($deleteFiles) { + $stagingDomainRoot = "{$userHome}/domains/{$siteDomain}"; + if (is_dir($stagingDomainRoot)) { + exec("rm -rf " . escapeshellarg($stagingDomainRoot)); + } + } + + $domainListFile = "{$userHome}/.domains"; + if (file_exists($domainListFile)) { + $domains = json_decode(file_get_contents($domainListFile), true) ?: []; + unset($domains[$siteDomain]); + file_put_contents($domainListFile, json_encode($domains, JSON_PRETTY_PRINT)); + @chown($domainListFile, $userInfo['uid']); + @chgrp($domainListFile, $userInfo['gid']); + } + + exec("nginx -t 2>&1", $nginxTestOutput, $nginxTestCode); + if ($nginxTestCode === 0) { + exec("systemctl reload nginx 2>&1"); + } + } elseif ($deleteFiles && !empty($site['install_path'])) { $installPath = $site['install_path']; // Safety check - make sure it's within user's domain folder if (strpos($installPath, "{$userHome}/domains/") === 0) { @@ -7991,8 +8022,50 @@ function wpAutoLogin(array $params): array } $site = $wpSites[$siteId]; - $installPath = $site['install_path']; - $adminUser = $site['admin_user']; + $installPath = $site['install_path'] ?? ''; + if ($installPath === '' || !is_dir($installPath)) { + return ['success' => false, 'error' => 'WordPress installation path not found']; + } + + $adminUser = trim((string) ($site['admin_user'] ?? '')); + if ($adminUser === '') { + exec( + "cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp user list --role=administrator --field=user_login --format=csv 2>&1", + $adminOutput, + $adminCode + ); + + if ($adminCode === 0) { + $adminLines = array_values(array_filter(array_map('trim', $adminOutput), fn ($line) => $line !== '' && $line !== 'user_login')); + if ($adminLines !== []) { + $adminUser = (string) $adminLines[0]; + } + } + } + + if ($adminUser === '') { + return ['success' => false, 'error' => 'Could not determine WordPress admin user']; + } + + // Persist a recovered admin username so future auto-login calls are fast and stable. + if (($site['admin_user'] ?? '') !== $adminUser) { + $wpSites[$siteId]['admin_user'] = $adminUser; + file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT)); + @chown($wpListFile, $userInfo['uid']); + @chgrp($wpListFile, $userInfo['gid']); + } + + $baseUrl = trim((string) ($site['url'] ?? '')); + if ($baseUrl === '') { + $domain = trim((string) ($site['domain'] ?? '')); + if ($domain !== '') { + $baseUrl = 'https://' . $domain; + } + } + + if ($baseUrl === '') { + return ['success' => false, 'error' => 'Could not determine site URL']; + } // Skip WP-CLI login package (slow) - use direct fallback method // Generate secure auto-login token @@ -8002,7 +8075,7 @@ function wpAutoLogin(array $params): array $token = bin2hex(random_bytes(32)); $expiry = time() + 300; // 5 minute expiry - $adminUrl = $site['url'] . '/wp-admin/'; + $adminUrl = rtrim($baseUrl, '/') . '/wp-admin/'; $autoLoginContent = ' true, 'login_url' => $loginUrl]; @@ -8265,7 +8338,8 @@ function wpCreateStaging(array $params): array { $username = $params['username'] ?? ''; $siteId = $params['site_id'] ?? ''; - $subdomain = $params['subdomain'] ?? 'staging'; + $subdomain = strtolower(trim((string) ($params['subdomain'] ?? 'staging'))); + $targetDomain = strtolower(trim((string) ($params['target_domain'] ?? ''))); if (!validateUsername($username)) { return ['success' => false, 'error' => 'Invalid username']; @@ -8290,15 +8364,412 @@ function wpCreateStaging(array $params): array $site = $wpSites[$siteId]; $sourcePath = $site['install_path']; - $sourceDomain = $site['domain']; + $sourceDomain = strtolower(trim((string) ($site['domain'] ?? ''))); + if ($sourceDomain === '' || !validateDomain($sourceDomain)) { + return ['success' => false, 'error' => 'Source domain is invalid']; + } - // Create staging domain - $stagingDomain = "{$subdomain}.{$sourceDomain}"; + // Resolve staging domain from either explicit target domain or subdomain label. + if ($targetDomain !== '') { + if (!validateDomain($targetDomain)) { + return ['success' => false, 'error' => 'Invalid target domain']; + } + if (!validateUserDomain($username, $targetDomain)) { + return ['success' => false, 'error' => 'Target domain does not belong to user']; + } + if ($targetDomain === $sourceDomain) { + return ['success' => false, 'error' => 'Target domain must be different from source domain']; + } + $stagingDomain = $targetDomain; + } else { + if ($subdomain === '') { + $subdomain = 'staging'; + } + if (preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i', $subdomain) !== 1) { + return ['success' => false, 'error' => 'Invalid subdomain label']; + } + $stagingDomain = "{$subdomain}.{$sourceDomain}"; + } + + $stagingDomainRoot = "{$userHome}/domains/{$stagingDomain}"; $stagingPath = "{$userHome}/domains/{$stagingDomain}/public_html"; + $stagingUrl = "https://{$stagingDomain}"; - // Check if staging already exists - if (is_dir($stagingPath)) { - return ['success' => false, 'error' => 'Staging site already exists at ' . $stagingDomain]; + $syncStagingUrls = function (string $targetPath, string $targetUrl, string $originalUrl, string $originalDomain, string $targetDomain) use ($username): array { + $replacePairs = []; + $normalizedOriginalUrl = trim($originalUrl); + if ($normalizedOriginalUrl !== '' && $normalizedOriginalUrl !== $targetUrl) { + $replacePairs[$normalizedOriginalUrl] = $targetUrl; + } + + $replacePairs["https://{$originalDomain}"] = "https://{$targetDomain}"; + $replacePairs["http://{$originalDomain}"] = "http://{$targetDomain}"; + // Avoid raw domain replacement because repeated syncs can duplicate subdomains + // (e.g. staging.example.com -> staging.staging.example.com). + + foreach ($replacePairs as $search => $replace) { + if ($search === '' || $search === $replace) { + continue; + } + + $replaceCmd = "cd " . escapeshellarg($targetPath) + . " && sudo -u " . escapeshellarg($username) + . " wp search-replace " . escapeshellarg($search) + . " " . escapeshellarg($replace) + . " --all-tables --skip-columns=guid --quiet 2>&1"; + exec($replaceCmd, $replaceOutput, $replaceCode); + if ($replaceCode !== 0) { + return ['success' => false, 'error' => 'Failed to update staging URLs']; + } + } + + // Repair any legacy duplicated-subdomain values created by previous sync logic. + $suffix = '.' . $originalDomain; + if (str_ends_with($targetDomain, $suffix)) { + $targetLabel = substr($targetDomain, 0, -strlen($suffix)); + $targetLabel = trim((string) $targetLabel, '.'); + if ($targetLabel !== '') { + $duplicateDomain = $targetLabel . '.' . $targetDomain; + if ($duplicateDomain !== $targetDomain) { + $cleanupPairs = [ + "https://{$duplicateDomain}" => "https://{$targetDomain}", + "http://{$duplicateDomain}" => "http://{$targetDomain}", + $duplicateDomain => $targetDomain, + ]; + + foreach ($cleanupPairs as $search => $replace) { + $cleanupCmd = "cd " . escapeshellarg($targetPath) + . " && sudo -u " . escapeshellarg($username) + . " wp search-replace " . escapeshellarg($search) + . " " . escapeshellarg($replace) + . " --all-tables --skip-columns=guid --quiet 2>&1"; + exec($cleanupCmd, $cleanupOutput, $cleanupCode); + if ($cleanupCode !== 0) { + return ['success' => false, 'error' => 'Failed to clean duplicated staging URLs']; + } + } + } + } + } + + $updateHomeCmd = "cd " . escapeshellarg($targetPath) + . " && sudo -u " . escapeshellarg($username) + . " wp option update home " . escapeshellarg($targetUrl) . " --quiet 2>&1"; + exec($updateHomeCmd, $updateHomeOutput, $updateHomeCode); + if ($updateHomeCode !== 0) { + return ['success' => false, 'error' => 'Failed to set staging home URL']; + } + + $updateSiteUrlCmd = "cd " . escapeshellarg($targetPath) + . " && sudo -u " . escapeshellarg($username) + . " wp option update siteurl " . escapeshellarg($targetUrl) . " --quiet 2>&1"; + exec($updateSiteUrlCmd, $updateSiteUrlOutput, $updateSiteUrlCode); + if ($updateSiteUrlCode !== 0) { + return ['success' => false, 'error' => 'Failed to set staging site URL']; + } + + return ['success' => true]; + }; + + $refreshStagingDatabase = function (string $sourceDatabase, string $targetDatabase) use ($siteId): array { + if ($sourceDatabase === '' || $targetDatabase === '') { + return ['success' => false, 'error' => 'Missing source or target database for staging refresh']; + } + + $dumpFile = "/tmp/wp_staging_refresh_{$siteId}_" . bin2hex(random_bytes(4)) . ".sql"; + + exec( + "mysqldump --defaults-file=/etc/mysql/debian.cnf " . escapeshellarg($sourceDatabase) . " > " . escapeshellarg($dumpFile) . " 2>&1", + $dumpOutput, + $dumpCode + ); + if ($dumpCode !== 0) { + @unlink($dumpFile); + return ['success' => false, 'error' => 'Failed to export source database for staging refresh']; + } + + $dropSql = "DROP DATABASE IF EXISTS `" . addslashes($targetDatabase) . "`"; + exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($dropSql) . " 2>&1", $dropOutput, $dropCode); + if ($dropCode !== 0) { + @unlink($dumpFile); + return ['success' => false, 'error' => 'Failed to reset staging database']; + } + + $createSql = "CREATE DATABASE `" . addslashes($targetDatabase) . "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"; + exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($createSql) . " 2>&1", $createOutput, $createCode); + if ($createCode !== 0) { + @unlink($dumpFile); + return ['success' => false, 'error' => 'Failed to recreate staging database']; + } + + exec( + "mysql --defaults-file=/etc/mysql/debian.cnf " . escapeshellarg($targetDatabase) . " < " . escapeshellarg($dumpFile) . " 2>&1", + $importOutput, + $importCode + ); + @unlink($dumpFile); + if ($importCode !== 0) { + return ['success' => false, 'error' => 'Failed to import refreshed staging database']; + } + + return ['success' => true]; + }; + + $syncStagingCacheIsolation = function (string $targetPath, string $targetDomain) use ($username, $userInfo): array { + $wpConfigPath = rtrim($targetPath, '/') . '/wp-config.php'; + $wpConfig = @file_get_contents($wpConfigPath); + if (!is_string($wpConfig) || $wpConfig === '') { + return ['success' => false, 'error' => 'Failed to read staging wp-config.php for cache isolation']; + } + + $domainToken = strtolower($targetDomain); + $domainToken = preg_replace('/[^a-z0-9_]+/', '_', $domainToken); + $domainToken = trim((string) $domainToken, '_'); + if ($domainToken === '') { + $domainToken = 'staging_site'; + } + + $cachePrefix = 'jc_' . substr($domainToken, 0, 44) . '_'; + $cacheSalt = 'jabali_stage_' . substr(hash('sha256', $targetDomain . '|' . $targetPath), 0, 40); + + $setConfigConstant = function (string $constantName, string $constantValue) use (&$wpConfig): bool { + $escapedValue = str_replace(['\\', '\''], ['\\\\', '\\\''], $constantValue); + $replacement = "define('{$constantName}', '{$escapedValue}');"; + $pattern = "/define\\s*\\(\\s*['\\\"]" . preg_quote($constantName, '/') . "['\\\"]\\s*,\\s*.+?\\)\\s*;/is"; + + if (preg_match($pattern, $wpConfig) === 1) { + $wpConfig = preg_replace($pattern, $replacement, $wpConfig, 1, $count); + return $count > 0; + } + + if (preg_match('/^<\\?php\\s*/i', $wpConfig) === 1) { + $wpConfig = preg_replace('/^<\\?php\\s*/i', " false, 'error' => 'Failed to set staging cache constants in wp-config.php']; + } + + if (@file_put_contents($wpConfigPath, $wpConfig) === false) { + return ['success' => false, 'error' => 'Failed to write staging cache constants to wp-config.php']; + } + @chown($wpConfigPath, $userInfo['uid']); + @chgrp($wpConfigPath, $userInfo['gid']); + + $flushCmd = "cd " . escapeshellarg($targetPath) + . " && sudo -u " . escapeshellarg($username) + . " wp cache flush 2>&1"; + exec($flushCmd, $flushOutput, $flushCode); + if ($flushCode !== 0) { + // Cache flush can fail when no object-cache drop-in is active. + // Keep migration flow successful as constants are already isolated. + } + + return [ + 'success' => true, + 'cache_prefix' => $cachePrefix, + ]; + }; + + // Check if staging is already tracked (idempotent for repeated create requests). + // Protect existing non-staging sites from accidental overwrite. + $existingTrackedId = null; + $existingTrackedSite = null; + foreach ($wpSites as $candidateId => $existingSite) { + $candidateDomain = strtolower(trim((string) ($existingSite['domain'] ?? ''))); + if ($candidateDomain !== $stagingDomain) { + continue; + } + + $candidateIsStaging = (bool) ($existingSite['is_staging'] ?? false); + $candidateSourceSiteId = (string) ($existingSite['source_site_id'] ?? ''); + + if ($candidateIsStaging && $candidateSourceSiteId === $siteId) { + $existingTrackedId = (string) $candidateId; + $existingTrackedSite = is_array($existingSite) ? $existingSite : null; + break; + } + + return ['success' => false, 'error' => 'Target domain already used by another WordPress site']; + } + + if ($existingTrackedId !== null && is_array($existingTrackedSite)) { + $existingInstallPath = trim((string) ($existingTrackedSite['install_path'] ?? '')); + $existingUrl = trim((string) ($existingTrackedSite['url'] ?? '')); + + if ($existingInstallPath !== '' && is_dir($existingInstallPath)) { + $trackedDbName = trim((string) ($existingTrackedSite['db_name'] ?? '')); + $trackedDbUser = trim((string) ($existingTrackedSite['db_user'] ?? '')); + $sourceDbNameForRefresh = ''; + $sourceDbUserForRefresh = ''; + + $sourceWpConfigPath = rtrim((string) $sourcePath, '/') . '/wp-config.php'; + $sourceWpConfig = @file_get_contents($sourceWpConfigPath); + if (is_string($sourceWpConfig) && $sourceWpConfig !== '') { + preg_match("/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*['\\\"]([^'\\\"]+)['\\\"]\\s*\\)/", $sourceWpConfig, $sourceDbNameMatch); + preg_match("/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*['\\\"]([^'\\\"]+)['\\\"]\\s*\\)/", $sourceWpConfig, $sourceDbUserMatch); + $sourceDbNameForRefresh = $sourceDbNameMatch[1] ?? ''; + $sourceDbUserForRefresh = $sourceDbUserMatch[1] ?? ''; + } + if ($sourceDbNameForRefresh === '') { + $sourceDbNameForRefresh = trim((string) ($site['db_name'] ?? '')); + } + if ($sourceDbUserForRefresh === '') { + $sourceDbUserForRefresh = trim((string) ($site['db_user'] ?? '')); + } + if ($sourceDbUserForRefresh === '') { + $sourceDbUserForRefresh = $sourceDbNameForRefresh; + } + + if ($sourceDbNameForRefresh === '') { + return ['success' => false, 'error' => 'Could not read source database name for staging refresh']; + } + + $trackedDbNameLower = strtolower($trackedDbName); + $trackedDbUserLower = strtolower($trackedDbUser); + $sourceDbNameLower = strtolower($sourceDbNameForRefresh); + $sourceDbUserLower = strtolower($sourceDbUserForRefresh); + + // Old staging entries may still reuse source DB credentials. + // Remove those tracked entries and recreate from scratch with isolated DB creds. + if ( + $trackedDbName === '' + || $trackedDbUser === '' + || $trackedDbNameLower === $sourceDbNameLower + || $trackedDbUserLower === $sourceDbUserLower + ) { + unset($wpSites[$existingTrackedId]); + file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT)); + + $staleVhost = "/etc/nginx/sites-available/{$stagingDomain}.conf"; + if (file_exists("/etc/nginx/sites-enabled/{$stagingDomain}.conf")) { + @unlink("/etc/nginx/sites-enabled/{$stagingDomain}.conf"); + } + if (file_exists($staleVhost)) { + @unlink($staleVhost); + } + if (is_dir($stagingDomainRoot)) { + exec("rm -rf " . escapeshellarg($stagingDomainRoot)); + } + } else { + if ($trackedDbName !== '' && $trackedDbUser !== '') { + $existingWpConfigPath = rtrim($existingInstallPath, '/') . '/wp-config.php'; + $existingWpConfig = @file_get_contents($existingWpConfigPath); + $configDbName = ''; + $configDbUser = ''; + + if (is_string($existingWpConfig) && $existingWpConfig !== '') { + preg_match("/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*['\\\"]([^'\\\"]+)['\\\"]\\s*\\)/", $existingWpConfig, $existingDbNameMatch); + preg_match("/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*['\\\"]([^'\\\"]+)['\\\"]\\s*\\)/", $existingWpConfig, $existingDbUserMatch); + $configDbName = $existingDbNameMatch[1] ?? ''; + $configDbUser = $existingDbUserMatch[1] ?? ''; + } + + // Self-heal old staging records where wp-config drifted from tracked DB credentials. + if ($configDbName !== $trackedDbName || $configDbUser !== $trackedDbUser) { + $repairedDbPass = bin2hex(random_bytes(12)); + $createUserSql = "CREATE USER IF NOT EXISTS '" . addslashes($trackedDbUser) . "'@'localhost' IDENTIFIED BY '" . addslashes($repairedDbPass) . "'"; + exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($createUserSql) . " 2>&1", $repairUserOutput, $repairUserCode); + if ($repairUserCode !== 0) { + return ['success' => false, 'error' => 'Failed to repair staging database user']; + } + + $alterUserSql = "ALTER USER '" . addslashes($trackedDbUser) . "'@'localhost' IDENTIFIED BY '" . addslashes($repairedDbPass) . "'"; + exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($alterUserSql) . " 2>&1", $repairAlterOutput, $repairAlterCode); + if ($repairAlterCode !== 0) { + return ['success' => false, 'error' => 'Failed to reset staging database user password']; + } + + $grantSql = "GRANT ALL PRIVILEGES ON `" . addslashes($trackedDbName) . "`.* TO '" . addslashes($trackedDbUser) . "'@'localhost'"; + exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($grantSql) . " 2>&1", $repairGrantOutput, $repairGrantCode); + if ($repairGrantCode !== 0) { + return ['success' => false, 'error' => 'Failed to repair staging database grants']; + } + + exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg("FLUSH PRIVILEGES") . " 2>&1"); + + $setExistingDbNameCmd = "cd " . escapeshellarg($existingInstallPath) . " && sudo -u " . escapeshellarg($username) + . " wp config set DB_NAME " . escapeshellarg($trackedDbName) . " --type=constant --quiet 2>&1"; + exec($setExistingDbNameCmd); + + $setExistingDbUserCmd = "cd " . escapeshellarg($existingInstallPath) . " && sudo -u " . escapeshellarg($username) + . " wp config set DB_USER " . escapeshellarg($trackedDbUser) . " --type=constant --quiet 2>&1"; + exec($setExistingDbUserCmd); + + $setExistingDbPassCmd = "cd " . escapeshellarg($existingInstallPath) . " && sudo -u " . escapeshellarg($username) + . " wp config set DB_PASSWORD " . escapeshellarg($repairedDbPass) . " --type=constant --quiet 2>&1"; + exec($setExistingDbPassCmd); + + $existingWpConfig = (string) @file_get_contents($existingWpConfigPath); + $existingWpConfig = preg_replace("/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*.+?\\)\\s*;/is", "define('DB_NAME', '{$trackedDbName}');", $existingWpConfig, 1); + $existingWpConfig = preg_replace("/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*.+?\\)\\s*;/is", "define('DB_USER', '{$trackedDbUser}');", $existingWpConfig, 1); + $existingWpConfig = preg_replace("/define\\s*\\(\\s*['\\\"]DB_PASSWORD['\\\"]\\s*,\\s*.+?\\)\\s*;/is", "define('DB_PASSWORD', '{$repairedDbPass}');", $existingWpConfig, 1); + file_put_contents($existingWpConfigPath, $existingWpConfig); + @chown($existingWpConfigPath, $userInfo['uid']); + @chgrp($existingWpConfigPath, $userInfo['gid']); + } + } + + if ($trackedDbName !== '') { + $refreshResult = $refreshStagingDatabase($sourceDbNameForRefresh, $trackedDbName); + if (!($refreshResult['success'] ?? false)) { + return ['success' => false, 'error' => $refreshResult['error'] ?? 'Failed to refresh staging database']; + } + } + + $urlSyncResult = $syncStagingUrls( + $existingInstallPath, + $stagingUrl, + (string) ($site['url'] ?? ''), + $sourceDomain, + $stagingDomain + ); + if (!($urlSyncResult['success'] ?? false)) { + return ['success' => false, 'error' => $urlSyncResult['error'] ?? 'Failed to sync staging URLs']; + } + + $cacheSyncResult = $syncStagingCacheIsolation($existingInstallPath, $stagingDomain); + if (!($cacheSyncResult['success'] ?? false)) { + return ['success' => false, 'error' => $cacheSyncResult['error'] ?? 'Failed to isolate staging cache settings']; + } + $wpSites[$existingTrackedId]['cache_prefix'] = (string) ($cacheSyncResult['cache_prefix'] ?? ''); + if (($site['cache_enabled'] ?? false) || file_exists($existingInstallPath . '/wp-content/object-cache.php')) { + $wpSites[$existingTrackedId]['cache_enabled'] = true; + } + file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT)); + + $existingUrl = $stagingUrl; + + return [ + 'success' => true, + 'staging_url' => $existingUrl, + 'staging_domain' => $stagingDomain, + 'staging_site_id' => $existingTrackedId, + ]; + } + } + + // Tracked entry is stale (path missing). Remove it and continue with fresh creation. + unset($wpSites[$existingTrackedId]); + file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT)); + } + + // Check for stale filesystem leftovers and self-heal before creating. + if (is_dir($stagingPath) || is_dir($stagingDomainRoot)) { + $staleVhost = "/etc/nginx/sites-available/{$stagingDomain}.conf"; + if (file_exists("/etc/nginx/sites-enabled/{$stagingDomain}.conf")) { + @unlink("/etc/nginx/sites-enabled/{$stagingDomain}.conf"); + } + if (file_exists($staleVhost)) { + @unlink($staleVhost); + } + exec("rm -rf " . escapeshellarg($stagingDomainRoot)); } // Create staging directories @@ -8346,19 +8817,53 @@ function wpCreateStaging(array $params): array $sourceDbUser = $dbUserMatch[1] ?? ''; $sourceDbPass = $dbPassMatch[1] ?? ''; + // Fallback to tracked metadata if wp-config uses non-string expressions. + if ($sourceDbName === '') { + $sourceDbName = (string) ($site['db_name'] ?? ''); + } + if ($sourceDbUser === '') { + $sourceDbUser = (string) ($site['db_user'] ?? ''); + } + if ($sourceDbUser === '') { + $sourceDbUser = $sourceDbName; + } + if (empty($sourceDbName)) { return ['success' => false, 'error' => 'Could not read source database name']; } + if (empty($sourceDbUser)) { + return ['success' => false, 'error' => 'Could not read source database user']; + } - // Create staging database and user - $suffix = preg_replace('/[^a-zA-Z0-9_]+/', '_', $subdomain); + // Create staging database and user. + // Keep suffix preserved so names cannot truncate back to source credentials. + $suffixSeed = $targetDomain !== '' ? $stagingDomain : $subdomain; + $suffix = preg_replace('/[^a-zA-Z0-9_]+/', '_', $suffixSeed); $suffix = trim((string) $suffix, '_'); if ($suffix === '') { $suffix = 'staging'; } - $stagingDbName = substr($sourceDbName . '_' . $suffix . '_stg', 0, 64); - $stagingDbUser = substr($sourceDbUser . '_' . $suffix . '_stg', 0, 32); + $buildStagingIdentifier = function (string $source, string $suffixValue, int $maxLength): string { + $token = '_' . $suffixValue . '_stg'; + if (strlen($token) >= $maxLength) { + $token = '_' . substr(sha1($suffixValue), 0, max(6, $maxLength - 2)); + } + + $baseMaxLength = max(1, $maxLength - strlen($token)); + $base = substr($source, 0, $baseMaxLength); + + return $base . $token; + }; + + $stagingDbName = $buildStagingIdentifier($sourceDbName, $suffix, 64); + $stagingDbUser = $buildStagingIdentifier($sourceDbUser, $suffix, 32); + if ($stagingDbName === $sourceDbName) { + $stagingDbName = substr($sourceDbName, 0, 54) . '_stg_' . substr(sha1($suffix), 0, 5); + } + if ($stagingDbUser === $sourceDbUser) { + $stagingDbUser = substr($sourceDbUser, 0, 22) . '_stg_' . substr(sha1($suffix), 0, 5); + } $stagingDbPass = bin2hex(random_bytes(12)); // Export and import database @@ -8401,32 +8906,89 @@ function wpCreateStaging(array $params): array return ['success' => false, 'error' => 'Failed to import database']; } - // Update wp-config.php with new database credentials - $wpConfig = preg_replace( - "/define\s*\(\s*['\"]DB_NAME['\"]\s*,\s*['\"][^'\"]+['\"]\s*\)/", - "define('DB_NAME', '{$stagingDbName}')", - $wpConfig - ); - $wpConfig = preg_replace( - "/define\s*\(\s*['\"]DB_USER['\"]\s*,\s*['\"][^'\"]+['\"]\s*\)/", - "define('DB_USER', '{$stagingDbUser}')", - $wpConfig - ); - $wpConfig = preg_replace( - "/define\s*\(\s*['\"]DB_PASSWORD['\"]\s*,\s*['\"][^'\"]*['\"]\s*\)/", - "define('DB_PASSWORD', '{$stagingDbPass}')", - $wpConfig - ); - file_put_contents($wpConfigPath, $wpConfig); + // Force-update wp-config.php with staging database credentials. + // First try WP-CLI config set (handles many formatting variants), then regex fallback. + $setDbNameCmd = "cd " . escapeshellarg($stagingPath) . " && sudo -u " . escapeshellarg($username) + . " wp config set DB_NAME " . escapeshellarg($stagingDbName) . " --type=constant --quiet 2>&1"; + exec($setDbNameCmd, $setDbNameOut, $setDbNameCode); - // Update URLs in staging database - $stagingUrl = "https://{$stagingDomain}"; - $sourceUrl = $site['url']; + $setDbUserCmd = "cd " . escapeshellarg($stagingPath) . " && sudo -u " . escapeshellarg($username) + . " wp config set DB_USER " . escapeshellarg($stagingDbUser) . " --type=constant --quiet 2>&1"; + exec($setDbUserCmd, $setDbUserOut, $setDbUserCode); - exec("cd " . escapeshellarg($stagingPath) . " && sudo -u " . escapeshellarg($username) . " wp search-replace " . escapeshellarg($sourceUrl) . " " . escapeshellarg($stagingUrl) . " --all-tables 2>&1"); + $setDbPassCmd = "cd " . escapeshellarg($stagingPath) . " && sudo -u " . escapeshellarg($username) + . " wp config set DB_PASSWORD " . escapeshellarg($stagingDbPass) . " --type=constant --quiet 2>&1"; + exec($setDbPassCmd, $setDbPassOut, $setDbPassCode); - // Also replace without protocol - exec("cd " . escapeshellarg($stagingPath) . " && sudo -u " . escapeshellarg($username) . " wp search-replace " . escapeshellarg($sourceDomain) . " " . escapeshellarg($stagingDomain) . " --all-tables 2>&1"); + $wpConfigAfterSet = @file_get_contents($wpConfigPath); + if (!is_string($wpConfigAfterSet) || $wpConfigAfterSet === '') { + return ['success' => false, 'error' => 'Failed to read staging wp-config.php after DB update']; + } + + $nameUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbName, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1; + $userUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbUser, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1; + $passUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_PASSWORD['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbPass, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1; + + if (!$nameUpdated || !$userUpdated || !$passUpdated) { + $nameCount = 0; + $userCount = 0; + $passCount = 0; + + $wpConfigAfterSet = preg_replace( + "/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*.+?\\)\\s*;/is", + "define('DB_NAME', '{$stagingDbName}');", + $wpConfigAfterSet, + 1, + $nameCount + ); + $wpConfigAfterSet = preg_replace( + "/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*.+?\\)\\s*;/is", + "define('DB_USER', '{$stagingDbUser}');", + $wpConfigAfterSet, + 1, + $userCount + ); + $wpConfigAfterSet = preg_replace( + "/define\\s*\\(\\s*['\\\"]DB_PASSWORD['\\\"]\\s*,\\s*.+?\\)\\s*;/is", + "define('DB_PASSWORD', '{$stagingDbPass}');", + $wpConfigAfterSet, + 1, + $passCount + ); + + if ($nameCount === 0 || $userCount === 0 || $passCount === 0) { + return ['success' => false, 'error' => 'Failed to update staging wp-config.php database constants']; + } + + file_put_contents($wpConfigPath, $wpConfigAfterSet); + @chown($wpConfigPath, $userInfo['uid']); + @chgrp($wpConfigPath, $userInfo['gid']); + + $wpConfigAfterSet = (string) @file_get_contents($wpConfigPath); + $nameUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbName, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1; + $userUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbUser, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1; + $passUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_PASSWORD['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbPass, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1; + } + + if (!$nameUpdated || !$userUpdated || !$passUpdated) { + return ['success' => false, 'error' => 'Staging wp-config.php database credentials verification failed']; + } + + $urlSyncResult = $syncStagingUrls( + $stagingPath, + $stagingUrl, + (string) ($site['url'] ?? ''), + $sourceDomain, + $stagingDomain + ); + if (!($urlSyncResult['success'] ?? false)) { + return ['success' => false, 'error' => $urlSyncResult['error'] ?? 'Failed to sync staging URLs']; + } + + $cacheSyncResult = $syncStagingCacheIsolation($stagingPath, $stagingDomain); + if (!($cacheSyncResult['success'] ?? false)) { + return ['success' => false, 'error' => $cacheSyncResult['error'] ?? 'Failed to isolate staging cache settings']; + } // Create Nginx config for staging (includes HTTPS with snakeoil cert) createFpmPool($username, false); @@ -8441,6 +9003,21 @@ function wpCreateStaging(array $params): array // Reload Nginx exec("systemctl reload nginx 2>&1"); + // Store staging domain in user's domain registry so it appears in Domain Manager. + $domainListFile = "{$userHome}/.domains"; + $domains = []; + if (file_exists($domainListFile)) { + $domains = json_decode(file_get_contents($domainListFile), true) ?: []; + } + $domains[$stagingDomain] = [ + 'created' => date('Y-m-d H:i:s'), + 'document_root' => $stagingPath, + 'ssl' => false, + ]; + file_put_contents($domainListFile, json_encode($domains, JSON_PRETTY_PRINT)); + @chown($domainListFile, $userInfo['uid']); + @chgrp($domainListFile, $userInfo['gid']); + // Add staging site to WordPress sites list $stagingSiteId = 'staging_' . $siteId . '_' . time(); $wpSites[$stagingSiteId] = [ @@ -8448,10 +9025,14 @@ function wpCreateStaging(array $params): array 'domain' => $stagingDomain, 'path' => '', 'url' => $stagingUrl, + 'admin_user' => (string) ($site['admin_user'] ?? 'admin'), + 'admin_email' => (string) ($site['admin_email'] ?? ''), 'install_path' => $stagingPath, 'db_name' => $stagingDbName, 'db_user' => $stagingDbUser, 'version' => $site['version'] ?? 'Unknown', + 'cache_enabled' => (bool) (($site['cache_enabled'] ?? false) || file_exists($stagingPath . '/wp-content/object-cache.php')), + 'cache_prefix' => (string) ($cacheSyncResult['cache_prefix'] ?? ''), 'is_staging' => true, 'source_site_id' => $siteId, 'created_at' => date('Y-m-d H:i:s'), diff --git a/resources/views/filament/jabali/pages/files.blade.php b/resources/views/filament/jabali/pages/files.blade.php index 3c23b0f..45e5699 100644 --- a/resources/views/filament/jabali/pages/files.blade.php +++ b/resources/views/filament/jabali/pages/files.blade.php @@ -1,4 +1,47 @@ + + {{-- Warning Banner --}} /dev/null | grep -q '^jabali-agent\\.service'; then systemctl restart jabali-agent; fi" +fi + echo "Deploy complete."