Fix subdomain DNS messaging for Ladill nameservers and retire pending-setup primaries.
Deploy Ladill Hosting / deploy (push) Successful in 47s
Deploy Ladill Hosting / deploy (push) Successful in 47s
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\HostingAccount;
|
||||||
|
use App\Services\Hosting\PlaceholderPrimaryDomainService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class RepairPlaceholderPrimaryDomainsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'hosting:repair-placeholder-primaries
|
||||||
|
{--account= : Limit to one hosting account id}
|
||||||
|
{--dry-run : Show what would change without writing}';
|
||||||
|
|
||||||
|
protected $description = 'Promote real linked domains over pending-setup.local / placeholder primaries.';
|
||||||
|
|
||||||
|
public function handle(PlaceholderPrimaryDomainService $placeholders): int
|
||||||
|
{
|
||||||
|
$accounts = HostingAccount::query()
|
||||||
|
->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1162,16 +1162,23 @@ class HostingPanelController extends Controller
|
|||||||
|
|
||||||
// Restore the soft-deleted site and recreate nginx config
|
// Restore the soft-deleted site and recreate nginx config
|
||||||
$existingSite->restore();
|
$existingSite->restore();
|
||||||
|
$placeholders = app(\App\Services\Hosting\PlaceholderPrimaryDomainService::class);
|
||||||
|
$promoteToPrimary = $placeholders->shouldBecomePrimary($account, $domainHost);
|
||||||
|
|
||||||
$existingSite->fill([
|
$existingSite->fill([
|
||||||
'document_root' => $docRoot,
|
'document_root' => $docRoot,
|
||||||
'php_version' => $account->php_version ?? $existingSite->php_version ?? '8.2',
|
'php_version' => $account->php_version ?? $existingSite->php_version ?? '8.2',
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
'type' => $existingSite->type ?: 'addon',
|
'type' => $promoteToPrimary ? 'primary' : ($existingSite->type ?: 'addon'),
|
||||||
])->save();
|
])->save();
|
||||||
try {
|
try {
|
||||||
$existingSite->loadMissing(['account.node']);
|
$existingSite->loadMissing(['account.node']);
|
||||||
$this->runtimeFor($account)->addSite($existingSite);
|
$this->runtimeFor($account)->addSite($existingSite);
|
||||||
|
|
||||||
|
if ($promoteToPrimary) {
|
||||||
|
$placeholders->promoteSite($account, $existingSite);
|
||||||
|
}
|
||||||
|
|
||||||
// Queue SSL provisioning for restored domain
|
// Queue SSL provisioning for restored domain
|
||||||
ProvisionHostingSslJob::dispatch($existingSite->id)->delay(now()->addMinutes(2));
|
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([
|
$site = HostedSite::create([
|
||||||
'hosting_account_id' => $account->id,
|
'hosting_account_id' => $account->id,
|
||||||
'domain_id' => $domain->id,
|
'domain_id' => $domain->id,
|
||||||
'domain' => $domainHost,
|
'domain' => $domainHost,
|
||||||
'document_root' => $docRoot,
|
'document_root' => $docRoot,
|
||||||
'type' => 'addon',
|
'type' => $promoteToPrimary ? 'primary' : 'addon',
|
||||||
'php_version' => $account->php_version ?? '8.2',
|
'php_version' => $account->php_version ?? '8.2',
|
||||||
'ssl_enabled' => false,
|
'ssl_enabled' => false,
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
@@ -1270,6 +1280,10 @@ class HostingPanelController extends Controller
|
|||||||
try {
|
try {
|
||||||
$this->runtimeFor($account)->addSite($site);
|
$this->runtimeFor($account)->addSite($site);
|
||||||
|
|
||||||
|
if ($promoteToPrimary) {
|
||||||
|
$placeholders->promoteSite($account, $site);
|
||||||
|
}
|
||||||
|
|
||||||
// Queue DNS zone provisioning (manual pack is local-only).
|
// Queue DNS zone provisioning (manual pack is local-only).
|
||||||
if ($onboardingMode === Domain::MODE_MANUAL_DNS) {
|
if ($onboardingMode === Domain::MODE_MANUAL_DNS) {
|
||||||
$verification->prepareManualDnsPack($domain);
|
$verification->prepareManualDnsPack($domain);
|
||||||
@@ -1368,14 +1382,23 @@ class HostingPanelController extends Controller
|
|||||||
try {
|
try {
|
||||||
$existingSite->loadMissing(['account.node']);
|
$existingSite->loadMissing(['account.node']);
|
||||||
$this->runtimeFor($account)->addSite($existingSite);
|
$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));
|
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()) {
|
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) {
|
} catch (\Exception $e) {
|
||||||
$existingSite->delete();
|
$existingSite->delete();
|
||||||
Log::error('Failed to restore subdomain nginx config: '.$e->getMessage());
|
Log::error('Failed to restore subdomain nginx config: '.$e->getMessage());
|
||||||
@@ -1416,12 +1439,17 @@ class HostingPanelController extends Controller
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$this->runtimeFor($account)->addSite($site);
|
$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));
|
ProvisionHostingSslJob::dispatch($site->id)->delay(now()->addMinutes(2));
|
||||||
|
|
||||||
$message = $dnsManaged
|
$message = match ($dnsResult['status']) {
|
||||||
? "Subdomain {$fqdn} created. DNS A record was added for the parent zone; SSL will provision automatically."
|
'managed' => "Subdomain {$fqdn} created. DNS was updated on Ladill nameservers; SSL will provision automatically.",
|
||||||
: "Subdomain {$fqdn} created. Point an A record for {$fqdn} to {$serverIp}, then 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()) {
|
if ($request->wantsJson()) {
|
||||||
return response()->json(['success' => true, 'message' => $message]);
|
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(
|
private function provisionSubdomainDns(
|
||||||
HostingAccount $account,
|
HostingAccount $account,
|
||||||
@@ -1470,14 +1498,20 @@ class HostingPanelController extends Controller
|
|||||||
string $fqdn,
|
string $fqdn,
|
||||||
string $serverIp,
|
string $serverIp,
|
||||||
PowerDnsClient $pdns,
|
PowerDnsClient $pdns,
|
||||||
): bool {
|
): array {
|
||||||
// HostedSite has both a `domain` column and a domain() relation — load via domain_id.
|
$parentDomain = $this->resolveParentDomainRecord($parent);
|
||||||
$parentDomain = $parent->domain_id
|
|
||||||
? Domain::query()->find($parent->domain_id)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (! $parentDomain || $parentDomain->dns_mode !== 'managed') {
|
if (! $parentDomain || ! $parentDomain->usesManagedDns()) {
|
||||||
return false;
|
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 {
|
try {
|
||||||
@@ -1487,7 +1521,18 @@ class HostingPanelController extends Controller
|
|||||||
'ttl' => 3600,
|
'ttl' => 3600,
|
||||||
'contents' => [$serverIp],
|
'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(
|
DomainDnsRecord::query()->updateOrCreate(
|
||||||
[
|
[
|
||||||
'domain_id' => $parentDomain->id,
|
'domain_id' => $parentDomain->id,
|
||||||
@@ -1503,17 +1548,32 @@ class HostingPanelController extends Controller
|
|||||||
'last_checked_at' => now(),
|
'last_checked_at' => now(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (\Throwable $e) {
|
} 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,
|
'fqdn' => $fqdn,
|
||||||
'parent_domain_id' => $parentDomain->id,
|
'parent_domain_id' => $parentDomain->id,
|
||||||
'error' => $e->getMessage(),
|
'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
|
private function cleanupSubdomainDns(HostedSite $site): void
|
||||||
@@ -1523,7 +1583,7 @@ class HostingPanelController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$parentDomain = Domain::query()->find($site->domain_id);
|
$parentDomain = Domain::query()->find($site->domain_id);
|
||||||
if (! $parentDomain || $parentDomain->dns_mode !== 'managed') {
|
if (! $parentDomain || ! $parentDomain->usesManagedDns()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1732,17 +1792,33 @@ class HostingPanelController extends Controller
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$this->runtimeFor($account)->requestLetsEncryptCertificate($site);
|
$this->runtimeFor($account)->requestLetsEncryptCertificate($site);
|
||||||
|
$expiresAt = now()->addDays(90);
|
||||||
$site->update([
|
$site->update([
|
||||||
'ssl_enabled' => true,
|
'ssl_enabled' => true,
|
||||||
'ssl_status' => 'issued',
|
'ssl_status' => 'issued',
|
||||||
'ssl_provisioned_at' => now(),
|
'ssl_provisioned_at' => now(),
|
||||||
|
'ssl_expires_at' => $expiresAt,
|
||||||
'ssl_error' => null,
|
'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.");
|
return back()->with('success', "SSL certificate issued for {$site->domain} successfully.");
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error("Failed to request SSL: " . $e->getMessage());
|
Log::error("Failed to request SSL: " . $e->getMessage());
|
||||||
|
|
||||||
|
$site->update([
|
||||||
|
'ssl_status' => 'failed',
|
||||||
|
'ssl_error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
$message = trim($e->getMessage());
|
$message = trim($e->getMessage());
|
||||||
|
|
||||||
return back()->with(
|
return back()->with(
|
||||||
|
|||||||
@@ -209,6 +209,13 @@ class HostingProductController extends Controller
|
|||||||
$renewals ??= app(HostingRenewalCheckoutService::class);
|
$renewals ??= app(HostingRenewalCheckoutService::class);
|
||||||
|
|
||||||
$account->load(['product', 'user', 'node', 'sites']);
|
$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();
|
$maxDomains = $account->maxDomainsLimit();
|
||||||
$freeEmailAllowance = $account->freeEmailAllowance();
|
$freeEmailAllowance = $account->freeEmailAllowance();
|
||||||
$mailboxes = $account->loadedMailboxes();
|
$mailboxes = $account->loadedMailboxes();
|
||||||
@@ -217,7 +224,11 @@ class HostingProductController extends Controller
|
|||||||
|
|
||||||
return view('hosting.account', [
|
return view('hosting.account', [
|
||||||
'account' => $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,
|
'maxDomains' => $maxDomains,
|
||||||
'canLinkMoreDomains' => $account->canAddDomain(),
|
'canLinkMoreDomains' => $account->canAddDomain(),
|
||||||
'ownedDomains' => collect($this->getUserDomains($request->user())),
|
'ownedDomains' => collect($this->getUserDomains($request->user())),
|
||||||
|
|||||||
@@ -141,12 +141,16 @@ class ProvisionHostingOrderJob implements ShouldQueue
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Create hosting account record
|
// Create hosting account record
|
||||||
|
$primaryDomain = \App\Services\Hosting\PlaceholderPrimaryDomainService::isPlaceholderDomain($this->order->domain_name)
|
||||||
|
? null
|
||||||
|
: $this->order->domain_name;
|
||||||
|
|
||||||
$account = HostingAccount::create([
|
$account = HostingAccount::create([
|
||||||
'user_id' => $this->order->user_id,
|
'user_id' => $this->order->user_id,
|
||||||
'hosting_node_id' => $node->id,
|
'hosting_node_id' => $node->id,
|
||||||
'hosting_product_id' => $product->id,
|
'hosting_product_id' => $product->id,
|
||||||
'username' => $username,
|
'username' => $username,
|
||||||
'primary_domain' => $this->order->domain_name,
|
'primary_domain' => $primaryDomain,
|
||||||
'type' => HostingAccount::TYPE_SHARED,
|
'type' => HostingAccount::TYPE_SHARED,
|
||||||
'status' => HostingAccount::STATUS_ACTIVE,
|
'status' => HostingAccount::STATUS_ACTIVE,
|
||||||
'home_directory' => $result['home_directory'] ?? "/home/{$username}",
|
'home_directory' => $result['home_directory'] ?? "/home/{$username}",
|
||||||
@@ -178,16 +182,18 @@ class ProvisionHostingOrderJob implements ShouldQueue
|
|||||||
], $resourceLimits),
|
], $resourceLimits),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
HostedSite::create([
|
if ($primaryDomain) {
|
||||||
'hosting_account_id' => $account->id,
|
HostedSite::create([
|
||||||
'domain_id' => $this->order->domain_id,
|
'hosting_account_id' => $account->id,
|
||||||
'domain' => $this->order->domain_name,
|
'domain_id' => $this->order->domain_id,
|
||||||
'document_root' => ($result['document_root'] ?? "/home/{$username}/public_html"),
|
'domain' => $primaryDomain,
|
||||||
'type' => 'primary',
|
'document_root' => ($result['document_root'] ?? "/home/{$username}/public_html"),
|
||||||
'status' => 'active',
|
'type' => 'primary',
|
||||||
'php_version' => '8.4',
|
'status' => 'active',
|
||||||
'ssl_enabled' => false,
|
'php_version' => '8.4',
|
||||||
]);
|
'ssl_enabled' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$sharedProvider->applyAccountResourceProfile($account, false);
|
$sharedProvider->applyAccountResourceProfile($account, false);
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ class ProvisionHostingSslJob implements ShouldQueue
|
|||||||
'ssl_expires_at' => now()->addDays(90),
|
'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', [
|
Log::info('ProvisionHostingSslJob: SSL provisioned successfully', [
|
||||||
'site_id' => $this->siteId,
|
'site_id' => $this->siteId,
|
||||||
'domain' => $site->domain,
|
'domain' => $site->domain,
|
||||||
|
|||||||
@@ -121,4 +121,17 @@ class Domain extends Model
|
|||||||
return (string) $this->status === 'verified'
|
return (string) $this->status === 'verified'
|
||||||
&& (string) $this->onboarding_state === self::STATE_ACTIVE;
|
&& (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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,13 @@ class AdminHostingAssignmentService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$primaryDomain = isset($input['primary_domain']) && $input['primary_domain'] !== ''
|
$primaryDomain = isset($input['primary_domain']) && $input['primary_domain'] !== ''
|
||||||
? (string) $input['primary_domain']
|
? strtolower(trim((string) $input['primary_domain']))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if ($primaryDomain !== null && PlaceholderPrimaryDomainService::isPlaceholderDomain($primaryDomain)) {
|
||||||
|
$primaryDomain = null;
|
||||||
|
}
|
||||||
|
|
||||||
$accountType = match ($product->type) {
|
$accountType = match ($product->type) {
|
||||||
'single_domain', 'multi_domain', 'wordpress' => HostingAccount::TYPE_SHARED,
|
'single_domain', 'multi_domain', 'wordpress' => HostingAccount::TYPE_SHARED,
|
||||||
'vps' => HostingAccount::TYPE_VPS,
|
'vps' => HostingAccount::TYPE_VPS,
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting;
|
||||||
|
|
||||||
|
use App\Models\HostedSite;
|
||||||
|
use App\Models\HostingAccount;
|
||||||
|
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sales Centre used to stamp pending-setup.local as primary_domain (and sometimes a
|
||||||
|
* primary HostedSite). Link Domain historically only created addons, so the
|
||||||
|
* placeholder kept hijacking the account label. This service promotes a real
|
||||||
|
* linked domain and retires placeholder primaries.
|
||||||
|
*/
|
||||||
|
class PlaceholderPrimaryDomainService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SharedNodeProvider $provider,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function isPlaceholderDomain(?string $domain): bool
|
||||||
|
{
|
||||||
|
$domain = strtolower(trim((string) $domain));
|
||||||
|
|
||||||
|
if ($domain === '' || $domain === 'pending-setup.local') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temporary nginx host used when an account is provisioned without a domain.
|
||||||
|
return str_ends_with($domain, '.ladill.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function accountNeedsPromotion(HostingAccount $account): bool
|
||||||
|
{
|
||||||
|
$account->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (Schema::hasTable('domain_dns_records')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::create('domain_dns_records', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
<nav class="flex items-center gap-1.5 text-sm text-slate-500">
|
<nav class="flex items-center gap-1.5 text-sm text-slate-500">
|
||||||
<a href="{{ route('hosting.single-domain') }}" class="hover:text-slate-700 transition">Hosting</a>
|
<a href="{{ route('hosting.single-domain') }}" class="hover:text-slate-700 transition">Hosting</a>
|
||||||
<svg class="h-3.5 w-3.5 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5m0 0v-15"/></svg>
|
<svg class="h-3.5 w-3.5 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5m0 0v-15"/></svg>
|
||||||
<span class="font-medium text-slate-800">{{ $account->primary_domain ?: $account->username }}</span>
|
<span class="font-medium text-slate-800">{{ $accountLabel ?? ($account->primary_domain ?: $account->username) }}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{{-- Expired Account Banner --}}
|
{{-- Expired Account Banner --}}
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold tracking-tight text-slate-900">Hosting Account</h2>
|
<h2 class="text-xl font-bold tracking-tight text-slate-900">Hosting Account</h2>
|
||||||
<div class="mt-1 flex flex-wrap items-center gap-2.5">
|
<div class="mt-1 flex flex-wrap items-center gap-2.5">
|
||||||
<span class="text-sm text-slate-700">{{ $account->primary_domain ?: $account->username }}</span>
|
<span class="text-sm text-slate-700">{{ $accountLabel ?? ($account->primary_domain ?: $account->username) }}</span>
|
||||||
<span class="rounded-full px-2.5 py-0.5 text-[11px] font-semibold {{ $statusColors[$account->status ?? 'pending'] }}">
|
<span class="rounded-full px-2.5 py-0.5 text-[11px] font-semibold {{ $statusColors[$account->status ?? 'pending'] }}">
|
||||||
{{ $statusLabels[$account->status ?? 'Pending'] }}
|
{{ $statusLabels[$account->status ?? 'Pending'] }}
|
||||||
</span>
|
</span>
|
||||||
@@ -442,7 +442,7 @@
|
|||||||
@endif
|
@endif
|
||||||
· {{ $account->subdomainSitesCount() }}{{ $account->maxSubdomainsLimit() === -1 ? '' : ' of '.$account->maxSubdomainsLimit() }} subdomain {{ $account->subdomainSitesCount() === 1 ? 'slot' : 'slots' }} in use.
|
· {{ $account->subdomainSitesCount() }}{{ $account->maxSubdomainsLimit() === -1 ? '' : ' of '.$account->maxSubdomainsLimit() }} subdomain {{ $account->subdomainSitesCount() === 1 ? 'slot' : 'slots' }} in use.
|
||||||
</p>
|
</p>
|
||||||
@elseif ($account->primary_domain)
|
@elseif ($account->primary_domain && ! \App\Services\Hosting\PlaceholderPrimaryDomainService::isPlaceholderDomain($account->primary_domain))
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm font-medium text-slate-800">{{ $account->primary_domain }}</span>
|
<span class="text-sm font-medium text-slate-800">{{ $account->primary_domain }}</span>
|
||||||
<span class="rounded-full px-2 py-0.5 text-[11px] font-semibold bg-emerald-100 text-emerald-700">
|
<span class="rounded-full px-2 py-0.5 text-[11px] font-semibold bg-emerald-100 text-emerald-700">
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-base font-semibold text-slate-900">Renew Hosting Plan</h3>
|
<h3 class="text-base font-semibold text-slate-900">Renew Hosting Plan</h3>
|
||||||
<p class="mt-0.5 text-xs text-slate-500">{{ $account->primary_domain ?: $account->username }}</p>
|
<p class="mt-0.5 text-xs text-slate-500">{{ $accountLabel ?? ($account->primary_domain ?: $account->username) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click="renewModal = false" type="button" class="rounded-lg p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition">
|
<button @click="renewModal = false" type="button" class="rounded-lg p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition">
|
||||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||||
|
|||||||
@@ -39,11 +39,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
@if($site->ssl_enabled)
|
@if($site->ssl_enabled || $site->ssl_status === 'issued')
|
||||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-800">
|
<span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-800">
|
||||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg>
|
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg>
|
||||||
SSL Active
|
SSL Active
|
||||||
</span>
|
</span>
|
||||||
|
@elseif($site->ssl_status === 'provisioning')
|
||||||
|
<div>
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-800">
|
||||||
|
Provisioning…
|
||||||
|
</span>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">Waiting for DNS / Let's Encrypt</p>
|
||||||
|
</div>
|
||||||
|
@elseif($site->ssl_status === 'failed')
|
||||||
|
<div>
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-100 px-2.5 py-1 text-xs font-medium text-red-800">
|
||||||
|
SSL Failed
|
||||||
|
</span>
|
||||||
|
@if($site->ssl_error)
|
||||||
|
<p class="mt-1 max-w-xs text-xs text-slate-500 truncate" title="{{ $site->ssl_error }}">{{ \Illuminate\Support\Str::limit($site->ssl_error, 80) }}</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@else
|
@else
|
||||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
|
<span class="inline-flex items-center gap-1.5 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
|
||||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg>
|
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg>
|
||||||
@@ -52,16 +68,16 @@
|
|||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||||
@if(!$site->ssl_enabled)
|
@if($site->ssl_enabled || $site->ssl_status === 'issued')
|
||||||
|
<span class="text-xs text-slate-500">Auto-renews</span>
|
||||||
|
@else
|
||||||
<form action="{{ route('hosting.panel.ssl.request', [$account, $site]) }}" method="POST" class="inline">
|
<form action="{{ route('hosting.panel.ssl.request', [$account, $site]) }}" method="POST" class="inline">
|
||||||
@csrf
|
@csrf
|
||||||
<button type="submit" class="btn-primary btn-primary-sm">
|
<button type="submit" class="btn-primary btn-primary-sm">
|
||||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/></svg>
|
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/></svg>
|
||||||
Issue SSL
|
{{ $site->ssl_status === 'failed' ? 'Retry SSL' : 'Issue SSL' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@else
|
|
||||||
<span class="text-xs text-slate-500">Auto-renews</span>
|
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -111,9 +111,161 @@ class HostingPanelDomainLinkingTest extends TestCase
|
|||||||
'hosting_account_id' => $account->id,
|
'hosting_account_id' => $account->id,
|
||||||
'domain' => 'linked-example.com',
|
'domain' => 'linked-example.com',
|
||||||
'domain_id' => $domain->id,
|
'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
|
public function test_hosting_account_page_renders_linked_domains(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@@ -312,6 +464,10 @@ class HostingPanelDomainLinkingTest extends TestCase
|
|||||||
);
|
);
|
||||||
|
|
||||||
$this->assertSame(302, $response->getStatusCode());
|
$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', [
|
$this->assertDatabaseHas('hosted_sites', [
|
||||||
'hosting_account_id' => $account->id,
|
'hosting_account_id' => $account->id,
|
||||||
'domain' => 'blog.primary.example.com',
|
'domain' => 'blog.primary.example.com',
|
||||||
@@ -323,6 +479,56 @@ class HostingPanelDomainLinkingTest extends TestCase
|
|||||||
$this->assertSame(5, $account->fresh()->maxSubdomainsLimit());
|
$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
|
public function test_subdomain_quota_is_max_domains_times_five(): void
|
||||||
{
|
{
|
||||||
[$user, $account, $parent] = $this->makeAccountWithPrimary(maxDomains: 1, withManagedDomain: false);
|
[$user, $account, $parent] = $this->makeAccountWithPrimary(maxDomains: 1, withManagedDomain: false);
|
||||||
|
|||||||
Reference in New Issue
Block a user