From 136cf8b7194f90dc7d33929dde8177cd2c5089af Mon Sep 17 00:00:00 2001 From: isaacclad Date: Mon, 13 Jul 2026 16:47:50 +0000 Subject: [PATCH] Fix subdomain DNS messaging for Ladill nameservers and retire pending-setup primaries. Treat ns_auto domains as managed DNS, publish subdomain A records via PowerDNS without failing the UX when the local DNS registry is missing, and promote real linked domains over pending-setup.local placeholders. Co-authored-by: Cursor --- ...RepairPlaceholderPrimaryDomainsCommand.php | 69 ++++++ .../Hosting/HostingPanelController.php | 122 +++++++++-- .../Hosting/HostingProductController.php | 13 +- app/Jobs/ProvisionHostingOrderJob.php | 28 ++- app/Jobs/ProvisionHostingSslJob.php | 7 + app/Models/Domain.php | 13 ++ .../Hosting/AdminHostingAssignmentService.php | 6 +- .../PlaceholderPrimaryDomainService.php | 194 +++++++++++++++++ ...164500_create_domain_dns_records_table.php | 39 ++++ resources/views/hosting/account.blade.php | 8 +- resources/views/hosting/panel/ssl.blade.php | 26 ++- .../Feature/HostingPanelDomainLinkingTest.php | 206 ++++++++++++++++++ 12 files changed, 686 insertions(+), 45 deletions(-) create mode 100644 app/Console/Commands/RepairPlaceholderPrimaryDomainsCommand.php create mode 100644 app/Services/Hosting/PlaceholderPrimaryDomainService.php create mode 100644 database/migrations/2026_07_13_164500_create_domain_dns_records_table.php diff --git a/app/Console/Commands/RepairPlaceholderPrimaryDomainsCommand.php b/app/Console/Commands/RepairPlaceholderPrimaryDomainsCommand.php new file mode 100644 index 0000000..1a1e4f0 --- /dev/null +++ b/app/Console/Commands/RepairPlaceholderPrimaryDomainsCommand.php @@ -0,0 +1,69 @@ +with(['sites', 'node']) + ->when($this->option('account'), fn ($q, $id) => $q->whereKey($id)) + ->orderBy('id') + ->get() + ->filter(fn (HostingAccount $account) => $placeholders->accountNeedsPromotion($account) + || $account->sites->contains( + fn ($site) => PlaceholderPrimaryDomainService::isPlaceholderDomain($site->domain) + )); + + if ($accounts->isEmpty()) { + $this->info('No accounts need placeholder primary repair.'); + + return self::SUCCESS; + } + + $healed = 0; + + foreach ($accounts as $account) { + $before = $account->primary_domain ?: '(null)'; + $this->line(sprintf( + '[%d] %s primary=%s sites=%s', + $account->id, + $account->username, + $before, + $account->sites->pluck('domain')->implode(', ') ?: '(none)' + )); + + if ($this->option('dry-run')) { + continue; + } + + if ($placeholders->healAccount($account)) { + $account->refresh(); + $this->info(sprintf( + ' → healed to primary=%s', + $account->primary_domain ?: '(null)' + )); + $healed++; + } + } + + if ($this->option('dry-run')) { + $this->warn('Dry run: no changes applied.'); + } else { + $this->info("Healed {$healed} account(s)."); + } + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Hosting/HostingPanelController.php b/app/Http/Controllers/Hosting/HostingPanelController.php index fbe2238..75f9291 100644 --- a/app/Http/Controllers/Hosting/HostingPanelController.php +++ b/app/Http/Controllers/Hosting/HostingPanelController.php @@ -1162,16 +1162,23 @@ class HostingPanelController extends Controller // Restore the soft-deleted site and recreate nginx config $existingSite->restore(); + $placeholders = app(\App\Services\Hosting\PlaceholderPrimaryDomainService::class); + $promoteToPrimary = $placeholders->shouldBecomePrimary($account, $domainHost); + $existingSite->fill([ 'document_root' => $docRoot, 'php_version' => $account->php_version ?? $existingSite->php_version ?? '8.2', 'status' => 'active', - 'type' => $existingSite->type ?: 'addon', + 'type' => $promoteToPrimary ? 'primary' : ($existingSite->type ?: 'addon'), ])->save(); try { $existingSite->loadMissing(['account.node']); $this->runtimeFor($account)->addSite($existingSite); + if ($promoteToPrimary) { + $placeholders->promoteSite($account, $existingSite); + } + // Queue SSL provisioning for restored domain ProvisionHostingSslJob::dispatch($existingSite->id)->delay(now()->addMinutes(2)); @@ -1256,12 +1263,15 @@ class HostingPanelController extends Controller ]); } + $placeholders = app(\App\Services\Hosting\PlaceholderPrimaryDomainService::class); + $promoteToPrimary = $placeholders->shouldBecomePrimary($account, $domainHost); + $site = HostedSite::create([ 'hosting_account_id' => $account->id, 'domain_id' => $domain->id, 'domain' => $domainHost, 'document_root' => $docRoot, - 'type' => 'addon', + 'type' => $promoteToPrimary ? 'primary' : 'addon', 'php_version' => $account->php_version ?? '8.2', 'ssl_enabled' => false, 'status' => 'active', @@ -1270,6 +1280,10 @@ class HostingPanelController extends Controller try { $this->runtimeFor($account)->addSite($site); + if ($promoteToPrimary) { + $placeholders->promoteSite($account, $site); + } + // Queue DNS zone provisioning (manual pack is local-only). if ($onboardingMode === Domain::MODE_MANUAL_DNS) { $verification->prepareManualDnsPack($domain); @@ -1368,14 +1382,23 @@ class HostingPanelController extends Controller try { $existingSite->loadMissing(['account.node']); $this->runtimeFor($account)->addSite($existingSite); - $this->provisionSubdomainDns($account, $parent, $label, $fqdn, $serverIp, $pdns); + $dnsResult = $this->provisionSubdomainDns($account, $parent, $label, $fqdn, $serverIp, $pdns); + if (! empty($dnsResult['domain_id']) && (int) $existingSite->domain_id !== (int) $dnsResult['domain_id']) { + $existingSite->update(['domain_id' => $dnsResult['domain_id']]); + } ProvisionHostingSslJob::dispatch($existingSite->id)->delay(now()->addMinutes(2)); + $message = match ($dnsResult['status']) { + 'managed' => "Subdomain {$fqdn} has been restored. DNS was updated on Ladill nameservers; SSL will provision automatically.", + 'managed_failed' => "Subdomain {$fqdn} has been restored. Ladill nameservers are in use, but we could not publish the A record automatically. Check DNS for {$parent->domain} in Ladill Domains.", + default => "Subdomain {$fqdn} has been restored. Because this domain uses external DNS, add an A record for {$fqdn} → {$serverIp}. SSL will provision automatically.", + }; + if ($request->wantsJson()) { - return response()->json(['success' => true, 'message' => "Subdomain {$fqdn} has been restored."]); + return response()->json(['success' => true, 'message' => $message]); } - return back()->with('success', "Subdomain {$fqdn} has been restored. SSL will be provisioned automatically."); + return back()->with('success', $message); } catch (\Exception $e) { $existingSite->delete(); Log::error('Failed to restore subdomain nginx config: '.$e->getMessage()); @@ -1416,12 +1439,17 @@ class HostingPanelController extends Controller try { $this->runtimeFor($account)->addSite($site); - $dnsManaged = $this->provisionSubdomainDns($account, $parent, $label, $fqdn, $serverIp, $pdns); + $dnsResult = $this->provisionSubdomainDns($account, $parent, $label, $fqdn, $serverIp, $pdns); + if (! empty($dnsResult['domain_id']) && (int) $site->domain_id !== (int) $dnsResult['domain_id']) { + $site->update(['domain_id' => $dnsResult['domain_id']]); + } ProvisionHostingSslJob::dispatch($site->id)->delay(now()->addMinutes(2)); - $message = $dnsManaged - ? "Subdomain {$fqdn} created. DNS A record was added for the parent zone; SSL will provision automatically." - : "Subdomain {$fqdn} created. Point an A record for {$fqdn} to {$serverIp}, then SSL will provision automatically."; + $message = match ($dnsResult['status']) { + 'managed' => "Subdomain {$fqdn} created. DNS was updated on Ladill nameservers; SSL will provision automatically.", + 'managed_failed' => "Subdomain {$fqdn} created. Ladill nameservers are in use, but we could not publish the A record automatically. Check DNS for {$parent->domain} in Ladill Domains (expect {$fqdn} → {$serverIp}). SSL will retry once DNS resolves.", + default => "Subdomain {$fqdn} created. Because this domain uses external DNS, add an A record for {$fqdn} → {$serverIp}, then SSL will provision automatically.", + }; if ($request->wantsJson()) { return response()->json(['success' => true, 'message' => $message]); @@ -1461,7 +1489,7 @@ class HostingPanelController extends Controller } /** - * @return bool True when DNS was managed/updated automatically + * @return array{status: 'managed'|'managed_failed'|'manual', domain_id: ?int} */ private function provisionSubdomainDns( HostingAccount $account, @@ -1470,14 +1498,20 @@ class HostingPanelController extends Controller string $fqdn, string $serverIp, PowerDnsClient $pdns, - ): bool { - // HostedSite has both a `domain` column and a domain() relation — load via domain_id. - $parentDomain = $parent->domain_id - ? Domain::query()->find($parent->domain_id) - : null; + ): array { + $parentDomain = $this->resolveParentDomainRecord($parent); - if (! $parentDomain || $parentDomain->dns_mode !== 'managed') { - return false; + if (! $parentDomain || ! $parentDomain->usesManagedDns()) { + return ['status' => 'manual', 'domain_id' => $parentDomain?->id]; + } + + // Heal legacy rows that were on ns_auto but never stamped dns_mode=managed. + if ((string) $parentDomain->dns_mode !== 'managed') { + $parentDomain->update(['dns_mode' => 'managed']); + } + + if (! $parent->domain_id) { + $parent->update(['domain_id' => $parentDomain->id]); } try { @@ -1487,7 +1521,18 @@ class HostingPanelController extends Controller 'ttl' => 3600, 'contents' => [$serverIp], ]]); + } catch (\Throwable $e) { + Log::warning('Failed to provision subdomain DNS record', [ + 'fqdn' => $fqdn, + 'parent_domain_id' => $parentDomain->id, + 'error' => $e->getMessage(), + ]); + return ['status' => 'managed_failed', 'domain_id' => $parentDomain->id]; + } + + // Local registry is best-effort; PowerDNS publish is what matters for Ladill NS. + try { DomainDnsRecord::query()->updateOrCreate( [ 'domain_id' => $parentDomain->id, @@ -1503,17 +1548,32 @@ class HostingPanelController extends Controller 'last_checked_at' => now(), ], ); - - return true; } catch (\Throwable $e) { - Log::warning('Failed to provision subdomain DNS record', [ + Log::info('Subdomain DNS published but local domain_dns_records sync failed', [ 'fqdn' => $fqdn, 'parent_domain_id' => $parentDomain->id, 'error' => $e->getMessage(), ]); - - return false; } + + return ['status' => 'managed', 'domain_id' => $parentDomain->id]; + } + + private function resolveParentDomainRecord(HostedSite $parent): ?Domain + { + if ($parent->domain_id) { + $byId = Domain::query()->find($parent->domain_id); + if ($byId) { + return $byId; + } + } + + $host = strtolower(trim((string) $parent->getAttribute('domain'))); + if ($host === '') { + return null; + } + + return Domain::query()->where('host', $host)->first(); } private function cleanupSubdomainDns(HostedSite $site): void @@ -1523,7 +1583,7 @@ class HostingPanelController extends Controller } $parentDomain = Domain::query()->find($site->domain_id); - if (! $parentDomain || $parentDomain->dns_mode !== 'managed') { + if (! $parentDomain || ! $parentDomain->usesManagedDns()) { return; } @@ -1732,17 +1792,33 @@ class HostingPanelController extends Controller try { $this->runtimeFor($account)->requestLetsEncryptCertificate($site); + $expiresAt = now()->addDays(90); $site->update([ 'ssl_enabled' => true, 'ssl_status' => 'issued', 'ssl_provisioned_at' => now(), + 'ssl_expires_at' => $expiresAt, 'ssl_error' => null, ]); + if ($site->domain_id) { + Domain::query()->whereKey($site->domain_id)->update([ + 'ssl_status' => 'active', + 'ssl_expires_at' => $expiresAt, + ]); + } + + app(\App\Services\Ssl\CentralSslRegistry::class)->register($site->domain, $expiresAt); + return back()->with('success', "SSL certificate issued for {$site->domain} successfully."); } catch (\Exception $e) { Log::error("Failed to request SSL: " . $e->getMessage()); + $site->update([ + 'ssl_status' => 'failed', + 'ssl_error' => $e->getMessage(), + ]); + $message = trim($e->getMessage()); return back()->with( diff --git a/app/Http/Controllers/Hosting/HostingProductController.php b/app/Http/Controllers/Hosting/HostingProductController.php index eddbf18..01f24c2 100644 --- a/app/Http/Controllers/Hosting/HostingProductController.php +++ b/app/Http/Controllers/Hosting/HostingProductController.php @@ -209,6 +209,13 @@ class HostingProductController extends Controller $renewals ??= app(HostingRenewalCheckoutService::class); $account->load(['product', 'user', 'node', 'sites']); + + // Heal legacy Sales Centre placeholders (pending-setup.local) that stayed + // primary after a real domain was linked as an addon. + app(\App\Services\Hosting\PlaceholderPrimaryDomainService::class)->healAccount($account); + $account->refresh()->load(['product', 'user', 'node', 'sites']); + + $placeholders = app(\App\Services\Hosting\PlaceholderPrimaryDomainService::class); $maxDomains = $account->maxDomainsLimit(); $freeEmailAllowance = $account->freeEmailAllowance(); $mailboxes = $account->loadedMailboxes(); @@ -217,7 +224,11 @@ class HostingProductController extends Controller return view('hosting.account', [ 'account' => $account, - 'linkedSites' => $account->sites->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain])->values(), + 'accountLabel' => $placeholders->displayLabel($account), + 'linkedSites' => $account->sites + ->filter(fn ($site) => ! \App\Services\Hosting\PlaceholderPrimaryDomainService::isPlaceholderDomain($site->domain)) + ->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain]) + ->values(), 'maxDomains' => $maxDomains, 'canLinkMoreDomains' => $account->canAddDomain(), 'ownedDomains' => collect($this->getUserDomains($request->user())), diff --git a/app/Jobs/ProvisionHostingOrderJob.php b/app/Jobs/ProvisionHostingOrderJob.php index cbe7c22..e8058a7 100644 --- a/app/Jobs/ProvisionHostingOrderJob.php +++ b/app/Jobs/ProvisionHostingOrderJob.php @@ -141,12 +141,16 @@ class ProvisionHostingOrderJob implements ShouldQueue ]); // Create hosting account record + $primaryDomain = \App\Services\Hosting\PlaceholderPrimaryDomainService::isPlaceholderDomain($this->order->domain_name) + ? null + : $this->order->domain_name; + $account = HostingAccount::create([ 'user_id' => $this->order->user_id, 'hosting_node_id' => $node->id, 'hosting_product_id' => $product->id, 'username' => $username, - 'primary_domain' => $this->order->domain_name, + 'primary_domain' => $primaryDomain, 'type' => HostingAccount::TYPE_SHARED, 'status' => HostingAccount::STATUS_ACTIVE, 'home_directory' => $result['home_directory'] ?? "/home/{$username}", @@ -178,16 +182,18 @@ class ProvisionHostingOrderJob implements ShouldQueue ], $resourceLimits), ]); - HostedSite::create([ - 'hosting_account_id' => $account->id, - 'domain_id' => $this->order->domain_id, - 'domain' => $this->order->domain_name, - 'document_root' => ($result['document_root'] ?? "/home/{$username}/public_html"), - 'type' => 'primary', - 'status' => 'active', - 'php_version' => '8.4', - 'ssl_enabled' => false, - ]); + if ($primaryDomain) { + HostedSite::create([ + 'hosting_account_id' => $account->id, + 'domain_id' => $this->order->domain_id, + 'domain' => $primaryDomain, + 'document_root' => ($result['document_root'] ?? "/home/{$username}/public_html"), + 'type' => 'primary', + 'status' => 'active', + 'php_version' => '8.4', + 'ssl_enabled' => false, + ]); + } $sharedProvider->applyAccountResourceProfile($account, false); diff --git a/app/Jobs/ProvisionHostingSslJob.php b/app/Jobs/ProvisionHostingSslJob.php index 61cf610..3d159f0 100644 --- a/app/Jobs/ProvisionHostingSslJob.php +++ b/app/Jobs/ProvisionHostingSslJob.php @@ -76,6 +76,13 @@ class ProvisionHostingSslJob implements ShouldQueue 'ssl_expires_at' => now()->addDays(90), ]); + if ($site->domain_id) { + \App\Models\Domain::query()->whereKey($site->domain_id)->update([ + 'ssl_status' => 'active', + 'ssl_expires_at' => $site->ssl_expires_at, + ]); + } + Log::info('ProvisionHostingSslJob: SSL provisioned successfully', [ 'site_id' => $this->siteId, 'domain' => $site->domain, diff --git a/app/Models/Domain.php b/app/Models/Domain.php index eeef5d7..82d34dd 100644 --- a/app/Models/Domain.php +++ b/app/Models/Domain.php @@ -121,4 +121,17 @@ class Domain extends Model return (string) $this->status === 'verified' && (string) $this->onboarding_state === self::STATE_ACTIVE; } + + /** True when Ladill should publish zone records (nameserver / managed DNS path). */ + public function usesManagedDns(): bool + { + if ((string) $this->dns_mode === 'managed') { + return true; + } + + return in_array((string) $this->onboarding_mode, [ + self::MODE_NS_AUTO, + self::MODE_RESELLER_AUTO, + ], true); + } } diff --git a/app/Services/Hosting/AdminHostingAssignmentService.php b/app/Services/Hosting/AdminHostingAssignmentService.php index 8856f51..317bf57 100644 --- a/app/Services/Hosting/AdminHostingAssignmentService.php +++ b/app/Services/Hosting/AdminHostingAssignmentService.php @@ -42,9 +42,13 @@ class AdminHostingAssignmentService } $primaryDomain = isset($input['primary_domain']) && $input['primary_domain'] !== '' - ? (string) $input['primary_domain'] + ? strtolower(trim((string) $input['primary_domain'])) : null; + if ($primaryDomain !== null && PlaceholderPrimaryDomainService::isPlaceholderDomain($primaryDomain)) { + $primaryDomain = null; + } + $accountType = match ($product->type) { 'single_domain', 'multi_domain', 'wordpress' => HostingAccount::TYPE_SHARED, 'vps' => HostingAccount::TYPE_VPS, diff --git a/app/Services/Hosting/PlaceholderPrimaryDomainService.php b/app/Services/Hosting/PlaceholderPrimaryDomainService.php new file mode 100644 index 0000000..84775b1 --- /dev/null +++ b/app/Services/Hosting/PlaceholderPrimaryDomainService.php @@ -0,0 +1,194 @@ +loadMissing('sites'); + + $primarySites = $account->sites->where('type', 'primary')->values(); + $hasPlaceholderPrimarySite = $primarySites->contains( + fn (HostedSite $site): bool => static::isPlaceholderDomain($site->domain) + ); + $hasRealPrimarySite = $primarySites->contains( + fn (HostedSite $site): bool => ! static::isPlaceholderDomain($site->domain) + ); + + // Never steal primary from an account that already has a real primary site. + if ($hasRealPrimarySite) { + return false; + } + + // Placeholder primary_domain (incl. null/empty) or only placeholder primary sites. + if (static::isPlaceholderDomain($account->primary_domain) || $hasPlaceholderPrimarySite) { + return true; + } + + return false; + } + + /** + * Whether a newly linked host should be stored as the account primary site. + */ + public function shouldBecomePrimary(HostingAccount $account, string $domainHost): bool + { + if ($this->accountNeedsPromotion($account)) { + return true; + } + + $primary = strtolower(trim((string) $account->primary_domain)); + + return $primary !== '' + && $primary === strtolower(trim($domainHost)) + && ! $account->sites->contains( + fn (HostedSite $site): bool => $site->type === 'primary' + && ! static::isPlaceholderDomain($site->domain) + ); + } + + /** + * Prefer a real hosted site over a placeholder primary_domain for UI labels. + */ + public function displayLabel(HostingAccount $account): string + { + $account->loadMissing('sites'); + + $real = $account->sites + ->filter(fn (HostedSite $site): bool => ! static::isPlaceholderDomain($site->domain)) + ->sortBy(fn (HostedSite $site): array => [$site->type !== 'primary', $site->domain]) + ->values(); + + if ($real->isNotEmpty()) { + if ($real->count() === 1) { + return (string) $real->first()->domain; + } + + $preview = $real->take(2)->pluck('domain')->implode(', '); + + return $real->count() > 2 + ? "{$preview} +".($real->count() - 2) + : $preview; + } + + if (! static::isPlaceholderDomain($account->primary_domain)) { + return (string) $account->primary_domain; + } + + return (string) $account->username; + } + + public function promoteSite(HostingAccount $account, HostedSite $site): void + { + if (static::isPlaceholderDomain($site->domain)) { + return; + } + + $account->loadMissing(['sites', 'node']); + + $site->update(['type' => 'primary']); + + $account->update([ + 'primary_domain' => $site->domain, + ]); + + foreach ($account->sites as $other) { + if ((int) $other->id === (int) $site->id) { + continue; + } + + if ($other->type === 'primary' && static::isPlaceholderDomain($other->domain)) { + $this->retirePlaceholderSite($account, $other); + } elseif ($other->type === 'primary') { + $other->update(['type' => 'addon']); + } + } + + $account->unsetRelation('sites'); + } + + /** + * Heal accounts that already have a real linked domain but still carry a + * placeholder primary_domain / primary HostedSite. + */ + public function healAccount(HostingAccount $account): bool + { + if (! $this->accountNeedsPromotion($account)) { + return false; + } + + $account->loadMissing(['sites', 'node']); + + $candidate = $account->sites + ->filter(fn (HostedSite $site): bool => ! static::isPlaceholderDomain($site->domain) + && in_array($site->type, ['primary', 'addon'], true)) + ->sortBy(fn (HostedSite $site): array => [$site->type !== 'primary', $site->id]) + ->first(); + + if (! $candidate) { + // No real site yet — just clear a stale placeholder label. + if (static::isPlaceholderDomain($account->primary_domain) && filled($account->primary_domain)) { + $account->update(['primary_domain' => null]); + + foreach ($account->sites->where('type', 'primary') as $placeholder) { + if (static::isPlaceholderDomain($placeholder->domain)) { + $this->retirePlaceholderSite($account, $placeholder); + } + } + + return true; + } + + return false; + } + + $this->promoteSite($account, $candidate); + + return true; + } + + protected function retirePlaceholderSite(HostingAccount $account, HostedSite $site): void + { + try { + if ($account->node) { + $this->provider->removeSite($site); + } + } catch (\Throwable $e) { + Log::info('PlaceholderPrimaryDomainService: best-effort nginx cleanup failed', [ + 'site_id' => $site->id, + 'domain' => $site->domain, + 'error' => $e->getMessage(), + ]); + } + + $site->delete(); + } +} diff --git a/database/migrations/2026_07_13_164500_create_domain_dns_records_table.php b/database/migrations/2026_07_13_164500_create_domain_dns_records_table.php new file mode 100644 index 0000000..0e50ccb --- /dev/null +++ b/database/migrations/2026_07_13_164500_create_domain_dns_records_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('domain_id')->constrained('domains')->cascadeOnDelete(); + $table->string('name'); + $table->string('type', 20); + $table->text('value'); + $table->unsignedInteger('ttl')->default(3600); + $table->unsignedSmallInteger('priority')->nullable(); + $table->string('source')->default('managed'); + $table->string('status')->default('pending'); + $table->timestamp('last_checked_at')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['domain_id', 'source']); + $table->index(['domain_id', 'status']); + $table->unique(['domain_id', 'name', 'type', 'value'], 'domain_dns_records_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('domain_dns_records'); + } +}; diff --git a/resources/views/hosting/account.blade.php b/resources/views/hosting/account.blade.php index 5327229..2117c6e 100644 --- a/resources/views/hosting/account.blade.php +++ b/resources/views/hosting/account.blade.php @@ -82,7 +82,7 @@ {{-- Expired Account Banner --}} @@ -150,7 +150,7 @@

Hosting Account

- {{ $account->primary_domain ?: $account->username }} + {{ $accountLabel ?? ($account->primary_domain ?: $account->username) }} {{ $statusLabels[$account->status ?? 'Pending'] }} @@ -442,7 +442,7 @@ @endif · {{ $account->subdomainSitesCount() }}{{ $account->maxSubdomainsLimit() === -1 ? '' : ' of '.$account->maxSubdomainsLimit() }} subdomain {{ $account->subdomainSitesCount() === 1 ? 'slot' : 'slots' }} in use.

- @elseif ($account->primary_domain) + @elseif ($account->primary_domain && ! \App\Services\Hosting\PlaceholderPrimaryDomainService::isPlaceholderDomain($account->primary_domain))
{{ $account->primary_domain }} @@ -524,7 +524,7 @@

Renew Hosting Plan

-

{{ $account->primary_domain ?: $account->username }}

+

{{ $accountLabel ?? ($account->primary_domain ?: $account->username) }}

- @if($site->ssl_enabled) + @if($site->ssl_enabled || $site->ssl_status === 'issued') SSL Active + @elseif($site->ssl_status === 'provisioning') +
+ + Provisioning… + +

Waiting for DNS / Let's Encrypt

+
+ @elseif($site->ssl_status === 'failed') +
+ + SSL Failed + + @if($site->ssl_error) +

{{ \Illuminate\Support\Str::limit($site->ssl_error, 80) }}

+ @endif +
@else @@ -52,16 +68,16 @@ @endif - @if(!$site->ssl_enabled) + @if($site->ssl_enabled || $site->ssl_status === 'issued') + Auto-renews + @else
@csrf
- @else - Auto-renews @endif diff --git a/tests/Feature/HostingPanelDomainLinkingTest.php b/tests/Feature/HostingPanelDomainLinkingTest.php index 2c6b240..8267904 100644 --- a/tests/Feature/HostingPanelDomainLinkingTest.php +++ b/tests/Feature/HostingPanelDomainLinkingTest.php @@ -111,9 +111,161 @@ class HostingPanelDomainLinkingTest extends TestCase 'hosting_account_id' => $account->id, 'domain' => 'linked-example.com', 'domain_id' => $domain->id, + 'type' => 'addon', ]); } + public function test_add_domain_promotes_over_pending_setup_placeholder(): void + { + $user = User::factory()->create(); + + $product = HostingProduct::create([ + 'name' => 'Growth Plan', + 'slug' => 'growth-placeholder-promote-test', + 'category' => 'shared', + 'type' => 'multi_domain', + 'price_monthly' => 10, + 'max_domains' => 8, + ]); + + $node = HostingNode::create([ + 'name' => 'Local Node', + 'hostname' => 'local-node', + 'ip_address' => '127.0.0.1', + 'type' => 'shared', + 'provider' => 'local', + 'status' => 'active', + 'ssh_port' => '22', + ]); + + $account = HostingAccount::unguarded(function () use ($user, $node, $product) { + return HostingAccount::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'hosting_node_id' => $node->id, + 'username' => 'aminmohammedosei', + 'primary_domain' => 'pending-setup.local', + 'type' => 'shared', + 'status' => 'active', + 'php_version' => '8.2', + ]); + }); + + HostedSite::create([ + 'hosting_account_id' => $account->id, + 'domain' => 'pending-setup.local', + 'document_root' => '/home/aminmohammedosei/public_html', + 'type' => 'primary', + 'status' => 'active', + 'ssl_enabled' => false, + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('addSite')->once()->andReturn([ + 'document_root' => '/home/aminmohammedosei/public_html/amenscientifichospital.com', + 'domain' => 'amenscientifichospital.com', + ]); + $provider->shouldReceive('removeSite')->once()->andReturn(true); + + $verification = \Mockery::mock(DomainVerificationService::class); + $verification->shouldReceive('reprovision')->once(); + + $this->app->instance(SharedNodeProvider::class, $provider); + $this->app->instance(DomainVerificationService::class, $verification); + + $session = $this->app['session.store']; + $session->start(); + + $request = Request::create('http://localhost/hosting/panel/' . $account->id . '/domains', 'POST', [ + 'domain' => 'amenscientifichospital.com', + ], [], [], [ + 'HTTP_REFERER' => 'http://localhost/hosting/accounts/' . $account->id, + ]); + $request->setUserResolver(fn (): User => $user); + $request->setLaravelSession($session); + + $response = $this->app->make(HostingPanelController::class)->addDomain( + $request, + $account->fresh(['sites', 'node', 'product']), + $this->app->make(DomainDnsBlueprintService::class), + $this->app->make(DomainVerificationService::class), + ); + + $this->assertSame(302, $response->getStatusCode()); + + $account->refresh(); + $this->assertSame('amenscientifichospital.com', $account->primary_domain); + + $this->assertDatabaseHas('hosted_sites', [ + 'hosting_account_id' => $account->id, + 'domain' => 'amenscientifichospital.com', + 'type' => 'primary', + ]); + + $this->assertSoftDeleted('hosted_sites', [ + 'hosting_account_id' => $account->id, + 'domain' => 'pending-setup.local', + ]); + } + + public function test_account_page_heals_placeholder_primary_when_real_domain_already_linked(): void + { + $user = User::factory()->create(); + + $product = HostingProduct::create([ + 'name' => 'Growth Plan', + 'slug' => 'growth-heal-placeholder-test', + 'category' => 'shared', + 'type' => 'multi_domain', + 'price_monthly' => 10, + 'max_domains' => 8, + ]); + + $node = HostingNode::create([ + 'name' => 'Local Node', + 'hostname' => 'local-node', + 'ip_address' => '127.0.0.1', + 'type' => 'shared', + 'provider' => 'local', + 'status' => 'active', + 'ssh_port' => '22', + ]); + + $account = HostingAccount::unguarded(function () use ($user, $node, $product) { + return HostingAccount::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'hosting_node_id' => $node->id, + 'username' => 'acctuser', + 'primary_domain' => 'pending-setup.local', + 'type' => 'shared', + 'status' => 'active', + 'php_version' => '8.2', + ]); + }); + + HostedSite::create([ + 'hosting_account_id' => $account->id, + 'domain' => 'amenscientifichospital.com', + 'document_root' => '/home/acctuser/public_html/amenscientifichospital.com', + 'type' => 'addon', + 'status' => 'active', + 'ssl_enabled' => false, + ]); + + $request = Request::create('http://localhost/hosting/accounts/' . $account->id, 'GET'); + $request->setUserResolver(fn (): User => $user); + + $html = $this->app->make(HostingProductController::class) + ->showAccount($request, $account) + ->render(); + + $account->refresh(); + $this->assertSame('amenscientifichospital.com', $account->primary_domain); + $this->assertStringContainsString('amenscientifichospital.com', $html); + $this->assertStringNotContainsString('pending-setup.local', $html); + } + public function test_hosting_account_page_renders_linked_domains(): void { $user = User::factory()->create(); @@ -312,6 +464,10 @@ class HostingPanelDomainLinkingTest extends TestCase ); $this->assertSame(302, $response->getStatusCode()); + $this->assertSame( + 'Subdomain blog.primary.example.com created. DNS was updated on Ladill nameservers; SSL will provision automatically.', + $session->get('success') + ); $this->assertDatabaseHas('hosted_sites', [ 'hosting_account_id' => $account->id, 'domain' => 'blog.primary.example.com', @@ -323,6 +479,56 @@ class HostingPanelDomainLinkingTest extends TestCase $this->assertSame(5, $account->fresh()->maxSubdomainsLimit()); } + public function test_add_subdomain_treats_ns_auto_as_managed_even_without_dns_mode(): void + { + [$user, $account, $parent] = $this->makeAccountWithPrimary(maxDomains: 1, withManagedDomain: true); + + Domain::query()->whereKey($parent->domain_id)->update(['dns_mode' => null]); + $parent->update(['domain_id' => null]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('addSite')->once()->andReturn([ + 'document_root' => '/home/acctuser/public_html/data.primary.example.com', + 'domain' => 'data.primary.example.com', + ]); + + $pdns = \Mockery::mock(\App\Services\Dns\PowerDnsClient::class); + $pdns->shouldReceive('upsertRecords')->once()->andReturn(['status' => 'updated', 'count' => 1]); + + $this->app->instance(SharedNodeProvider::class, $provider); + $this->app->instance(\App\Services\Dns\PowerDnsClient::class, $pdns); + + $session = $this->app['session.store']; + $session->start(); + + $request = Request::create('http://localhost/hosting/panel/'.$account->id.'/domains/subdomains', 'POST', [ + 'parent_site_id' => $parent->id, + 'subdomain' => 'data', + ], [], [], [ + 'HTTP_REFERER' => 'http://localhost/hosting/panel/'.$account->id.'/domains', + ]); + $request->setUserResolver(fn (): User => $user); + $request->setLaravelSession($session); + + $response = $this->app->make(HostingPanelController::class)->addSubdomain( + $request, + $account->fresh(['sites', 'product', 'node']), + $this->app->make(\App\Services\Dns\PowerDnsClient::class), + ); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame( + 'Subdomain data.primary.example.com created. DNS was updated on Ladill nameservers; SSL will provision automatically.', + $session->get('success') + ); + $this->assertDatabaseHas('domains', [ + 'host' => 'primary.example.com', + 'dns_mode' => 'managed', + ]); + $this->assertStringNotContainsString('external DNS', (string) $session->get('success')); + $this->assertStringNotContainsString('Point an A record', (string) $session->get('success')); + } + public function test_subdomain_quota_is_max_domains_times_five(): void { [$user, $account, $parent] = $this->makeAccountWithPrimary(maxDomains: 1, withManagedDomain: false);