Improve staging flow, UI fixes, and deploy automation
This commit is contained in:
@@ -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();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
661
bin/jabali-agent
661
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 = '<?php
|
||||
// Auto-login script - expires after one use or 5 minutes
|
||||
$token = "' . $token . '";
|
||||
@@ -8036,7 +8109,7 @@ die("User not found.");
|
||||
exec("sudo -u " . escapeshellarg($username) . " chmod 644 " . escapeshellarg($autoLoginFile));
|
||||
@unlink($tempFile);
|
||||
|
||||
$loginUrl = $site['url'] . "/jabali-auto-login-{$token}.php?token={$token}";
|
||||
$loginUrl = rtrim($baseUrl, '/') . "/jabali-auto-login-{$token}.php?token={$token}";
|
||||
}
|
||||
|
||||
return ['success' => 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', "<?php\n{$replacement}\n", $wpConfig, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
$saltSet = $setConfigConstant('WP_CACHE_KEY_SALT', $cacheSalt);
|
||||
$prefixSet = $setConfigConstant('JABALI_CACHE_PREFIX', $cachePrefix);
|
||||
if (!$saltSet || !$prefixSet) {
|
||||
return ['success' => 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'),
|
||||
|
||||
@@ -1,4 +1,47 @@
|
||||
<x-filament-panels::page>
|
||||
<style>
|
||||
/* Compact spacing for File Manager rows */
|
||||
#file-dropzone .fi-ta-text:not(.fi-inline) {
|
||||
padding-top: 0.2rem !important;
|
||||
padding-bottom: 0.2rem !important;
|
||||
}
|
||||
|
||||
#file-dropzone .fi-ta-text-item {
|
||||
line-height: 1.05rem !important;
|
||||
}
|
||||
|
||||
#file-dropzone .fi-ta-record-content-ctn {
|
||||
gap: 0.25rem !important;
|
||||
padding-top: 0.25rem !important;
|
||||
padding-bottom: 0.25rem !important;
|
||||
}
|
||||
|
||||
#file-dropzone .fi-ta-record-checkbox {
|
||||
margin-top: 0.2rem !important;
|
||||
margin-bottom: 0.2rem !important;
|
||||
}
|
||||
|
||||
#file-dropzone td.fi-ta-cell.fi-ta-selection-cell,
|
||||
#file-dropzone td.fi-ta-cell.fi-ta-group-selection-cell {
|
||||
padding-top: 0.2rem !important;
|
||||
padding-bottom: 0.2rem !important;
|
||||
}
|
||||
|
||||
#file-dropzone td.fi-ta-cell:has(.fi-ta-actions) {
|
||||
padding-top: 0.2rem !important;
|
||||
padding-bottom: 0.2rem !important;
|
||||
}
|
||||
|
||||
#file-dropzone .fi-ta-actions {
|
||||
gap: 0.35rem !important;
|
||||
}
|
||||
|
||||
#file-dropzone .fi-ta-actions .fi-btn,
|
||||
#file-dropzone .fi-ta-actions .fi-icon-btn {
|
||||
min-height: 1.65rem !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
{{-- Warning Banner --}}
|
||||
<x-filament::section
|
||||
icon="heroicon-o-exclamation-triangle"
|
||||
|
||||
@@ -19,6 +19,7 @@ SKIP_COMPOSER=0
|
||||
SKIP_NPM=0
|
||||
SKIP_MIGRATE=0
|
||||
SKIP_CACHE=0
|
||||
SKIP_AGENT_RESTART=0
|
||||
DELETE_REMOTE=0
|
||||
DRY_RUN=0
|
||||
PUSH_GITEA=0
|
||||
@@ -39,6 +40,7 @@ Options:
|
||||
--skip-npm Skip npm install/build
|
||||
--skip-migrate Skip php artisan migrate
|
||||
--skip-cache Skip cache clear/rebuild
|
||||
--skip-agent-restart Skip restarting jabali-agent service
|
||||
--delete Pass --delete to rsync (dangerous)
|
||||
--dry-run Dry-run rsync only
|
||||
--push-gitea Push current branch to Gitea before deploy
|
||||
@@ -93,6 +95,10 @@ while [[ $# -gt 0 ]]; do
|
||||
SKIP_CACHE=1
|
||||
shift
|
||||
;;
|
||||
--skip-agent-restart)
|
||||
SKIP_AGENT_RESTART=1
|
||||
shift
|
||||
;;
|
||||
--delete)
|
||||
DELETE_REMOTE=1
|
||||
shift
|
||||
@@ -320,4 +326,9 @@ if [[ "$SKIP_CACHE" -eq 0 ]]; then
|
||||
remote_run_www "php artisan view:cache"
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_AGENT_RESTART" -eq 0 ]]; then
|
||||
echo "Restarting jabali-agent service..."
|
||||
remote_run "if systemctl list-unit-files jabali-agent.service --no-legend 2>/dev/null | grep -q '^jabali-agent\\.service'; then systemctl restart jabali-agent; fi"
|
||||
fi
|
||||
|
||||
echo "Deploy complete."
|
||||
|
||||
Reference in New Issue
Block a user