Add first-class hosting subdomain create/attach in Domains panel.
Deploy Ladill Hosting / deploy (push) Successful in 1m9s
Deploy Ladill Hosting / deploy (push) Successful in 1m9s
Customers can create label-based subdomains under primary/addon sites with a separate max_domains×5 quota, nginx/docroot/SSL provisioning, and managed DNS A records when Ladill controls the parent zone. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,7 @@ use App\Jobs\ProvisionHostingSslJob;
|
||||
use App\Models\AppInstallation;
|
||||
use App\Models\CustomerHostingOrder;
|
||||
use App\Models\Domain;
|
||||
use App\Models\DomainDnsRecord;
|
||||
use App\Models\HostedDatabase;
|
||||
use App\Models\HostedSite;
|
||||
use App\Models\HostingAccount;
|
||||
@@ -15,6 +16,7 @@ use App\Models\HostingOrder;
|
||||
use App\Models\RcServiceOrder;
|
||||
use App\Models\Website;
|
||||
use App\Models\WebsiteMember;
|
||||
use App\Services\Dns\PowerDnsClient;
|
||||
use App\Services\Domain\DomainDnsBlueprintService;
|
||||
use App\Services\Domain\DomainVerificationService;
|
||||
use App\Services\Hosting\Contracts\PanelRuntimeInterface;
|
||||
@@ -1074,10 +1076,21 @@ class HostingPanelController extends Controller
|
||||
$account->load(['node', 'product', 'sites']);
|
||||
|
||||
$ownedDomains = $this->availableHostingDomainsForUser($request->user()->id, $account);
|
||||
$parentSites = $account->sites
|
||||
->whereIn('type', ['primary', 'addon'])
|
||||
->sortBy(fn (HostedSite $site) => [$site->type !== 'primary', $site->domain])
|
||||
->values();
|
||||
|
||||
return view('hosting.panel.domains', [
|
||||
'account' => $account,
|
||||
'ownedDomains' => $ownedDomains,
|
||||
'parentSites' => $parentSites,
|
||||
'maxDomains' => $account->maxDomainsLimit(),
|
||||
'maxSubdomains' => $account->maxSubdomainsLimit(),
|
||||
'domainSitesCount' => $account->domainSitesCount(),
|
||||
'subdomainSitesCount' => $account->subdomainSitesCount(),
|
||||
'canAddDomain' => $account->canAddDomain(),
|
||||
'canAddSubdomain' => $account->canAddSubdomain(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1091,12 +1104,15 @@ class HostingPanelController extends Controller
|
||||
$this->authorizeForRequestUser($request, 'manageDomains', $account);
|
||||
$account->load(['node', 'product', 'sites']);
|
||||
|
||||
$maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1;
|
||||
if ($account->sites->count() >= $maxDomains) {
|
||||
if (! $account->canAddDomain()) {
|
||||
$maxDomains = $account->maxDomainsLimit();
|
||||
$message = $maxDomains === -1
|
||||
? 'You cannot add more domains right now.'
|
||||
: "You have reached the maximum number of domains ({$maxDomains}).";
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => "You have reached the maximum number of domains ({$maxDomains})."], 422);
|
||||
return response()->json(['message' => $message], 422);
|
||||
}
|
||||
return back()->with('error', "You have reached the maximum number of domains ({$maxDomains}).");
|
||||
return back()->with('error', $message);
|
||||
}
|
||||
|
||||
$isOwnedDomain = $request->boolean('is_owned_domain');
|
||||
@@ -1283,15 +1299,158 @@ class HostingPanelController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
public function addSubdomain(
|
||||
Request $request,
|
||||
HostingAccount $account,
|
||||
PowerDnsClient $pdns,
|
||||
): RedirectResponse|JsonResponse {
|
||||
$this->authorizeForRequestUser($request, 'manageDomains', $account);
|
||||
$account->load(['node', 'product', 'sites.domain']);
|
||||
|
||||
if (! $account->canAddSubdomain()) {
|
||||
$maxSubdomains = $account->maxSubdomainsLimit();
|
||||
$message = $maxSubdomains === -1
|
||||
? 'You cannot add more subdomains right now.'
|
||||
: "You have reached the maximum number of subdomains ({$maxSubdomains}).";
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => $message], 422);
|
||||
}
|
||||
|
||||
return back()->with('error', $message);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'parent_site_id' => ['required', 'integer'],
|
||||
'subdomain' => ['required', 'string', 'max:63', 'regex:/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/'],
|
||||
'document_root' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$label = strtolower(trim($validated['subdomain']));
|
||||
$reserved = ['www', 'mail', 'ftp', 'ns1', 'ns2'];
|
||||
if (in_array($label, $reserved, true)) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => "The subdomain \"{$label}\" is reserved."], 422);
|
||||
}
|
||||
|
||||
return back()->with('error', "The subdomain \"{$label}\" is reserved.");
|
||||
}
|
||||
|
||||
$parent = $account->sites
|
||||
->first(fn (HostedSite $site) => (int) $site->id === (int) $validated['parent_site_id']
|
||||
&& in_array($site->type, ['primary', 'addon'], true));
|
||||
|
||||
if (! $parent) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => 'Choose a parent domain attached to this hosting account.'], 422);
|
||||
}
|
||||
|
||||
return back()->with('error', 'Choose a parent domain attached to this hosting account.');
|
||||
}
|
||||
|
||||
$fqdn = $label.'.'.strtolower(trim((string) $parent->domain));
|
||||
$serverIp = $account->node?->ip_address ?? '161.97.138.149';
|
||||
$docRoot = ! empty($validated['document_root'])
|
||||
? '/home/'.$account->username.'/'.ltrim($validated['document_root'], '/')
|
||||
: '/home/'.$account->username.'/public_html/'.$fqdn;
|
||||
|
||||
$existingSite = HostedSite::withTrashed()->where('domain', $fqdn)->first();
|
||||
if ($existingSite) {
|
||||
if ($existingSite->trashed() && (int) $existingSite->hosting_account_id === (int) $account->id) {
|
||||
$existingSite->restore();
|
||||
$existingSite->fill([
|
||||
'document_root' => $docRoot,
|
||||
'php_version' => $account->php_version ?? $existingSite->php_version ?? '8.2',
|
||||
'status' => 'active',
|
||||
'type' => 'subdomain',
|
||||
'domain_id' => $parent->domain_id,
|
||||
])->save();
|
||||
|
||||
try {
|
||||
$existingSite->loadMissing(['account.node']);
|
||||
$this->runtimeFor($account)->addSite($existingSite);
|
||||
$this->provisionSubdomainDns($account, $parent, $label, $fqdn, $serverIp, $pdns);
|
||||
ProvisionHostingSslJob::dispatch($existingSite->id)->delay(now()->addMinutes(2));
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true, 'message' => "Subdomain {$fqdn} has been restored."]);
|
||||
}
|
||||
|
||||
return back()->with('success', "Subdomain {$fqdn} has been restored. SSL will be provisioned automatically.");
|
||||
} catch (\Exception $e) {
|
||||
$existingSite->delete();
|
||||
Log::error('Failed to restore subdomain nginx config: '.$e->getMessage());
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => 'Failed to restore subdomain. Please try again.'], 500);
|
||||
}
|
||||
|
||||
return back()->with('error', 'Failed to restore subdomain. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
if ((int) $existingSite->hosting_account_id !== (int) $account->id) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => 'This subdomain is already in use on another hosting account.'], 422);
|
||||
}
|
||||
|
||||
return back()->with('error', 'This subdomain is already in use on another hosting account.');
|
||||
}
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true, 'message' => "Subdomain {$fqdn} is already linked to this hosting account."]);
|
||||
}
|
||||
|
||||
return back()->with('success', "Subdomain {$fqdn} is already linked to this hosting account.");
|
||||
}
|
||||
|
||||
$site = HostedSite::create([
|
||||
'hosting_account_id' => $account->id,
|
||||
'domain_id' => $parent->domain_id,
|
||||
'domain' => $fqdn,
|
||||
'document_root' => $docRoot,
|
||||
'type' => 'subdomain',
|
||||
'php_version' => $account->php_version ?? '8.2',
|
||||
'ssl_enabled' => false,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->runtimeFor($account)->addSite($site);
|
||||
$dnsManaged = $this->provisionSubdomainDns($account, $parent, $label, $fqdn, $serverIp, $pdns);
|
||||
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.";
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true, 'message' => $message]);
|
||||
}
|
||||
|
||||
return back()->with('success', $message);
|
||||
} catch (\Exception $e) {
|
||||
$site->delete();
|
||||
Log::error('Failed to add subdomain: '.$e->getMessage());
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => 'Failed to create subdomain. Please try again.'], 500);
|
||||
}
|
||||
|
||||
return back()->with('error', 'Failed to create subdomain. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
public function removeDomain(Request $request, HostingAccount $account, HostedSite $site): RedirectResponse
|
||||
{
|
||||
abort_unless((int) $account->user_id === (int) $request->user()->id, 403);
|
||||
$this->ensureSiteBelongsToAccount($site, $account);
|
||||
|
||||
$account->load(['node']);
|
||||
$site->loadMissing('domain');
|
||||
|
||||
try {
|
||||
$this->runtimeFor($account)->removeSite($site);
|
||||
$this->cleanupSubdomainDns($site);
|
||||
$site->delete();
|
||||
|
||||
return back()->with('success', 'Domain removed successfully.');
|
||||
@@ -1301,6 +1460,105 @@ class HostingPanelController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool True when DNS was managed/updated automatically
|
||||
*/
|
||||
private function provisionSubdomainDns(
|
||||
HostingAccount $account,
|
||||
HostedSite $parent,
|
||||
string $label,
|
||||
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;
|
||||
|
||||
if (! $parentDomain || $parentDomain->dns_mode !== 'managed') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdns->upsertRecords((string) $parentDomain->host, [[
|
||||
'name' => $fqdn,
|
||||
'type' => 'A',
|
||||
'ttl' => 3600,
|
||||
'contents' => [$serverIp],
|
||||
]]);
|
||||
|
||||
DomainDnsRecord::query()->updateOrCreate(
|
||||
[
|
||||
'domain_id' => $parentDomain->id,
|
||||
'name' => $label,
|
||||
'type' => 'A',
|
||||
'value' => $serverIp,
|
||||
],
|
||||
[
|
||||
'ttl' => 3600,
|
||||
'source' => 'hosting_subdomain',
|
||||
'status' => 'active',
|
||||
'notes' => 'Subdomain for hosting account '.$account->id,
|
||||
'last_checked_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to provision subdomain DNS record', [
|
||||
'fqdn' => $fqdn,
|
||||
'parent_domain_id' => $parentDomain->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanupSubdomainDns(HostedSite $site): void
|
||||
{
|
||||
if ($site->type !== 'subdomain' || ! $site->domain_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parentDomain = Domain::query()->find($site->domain_id);
|
||||
if (! $parentDomain || $parentDomain->dns_mode !== 'managed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$host = strtolower(trim((string) $site->getAttribute('domain')));
|
||||
$apex = strtolower((string) $parentDomain->host);
|
||||
$suffix = '.'.$apex;
|
||||
if (! str_ends_with($host, $suffix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$label = substr($host, 0, -strlen($suffix));
|
||||
if ($label === '' || str_contains($label, '.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
app(PowerDnsClient::class)->deleteRecords($apex, [[
|
||||
'name' => $host,
|
||||
'type' => 'A',
|
||||
]]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('Best-effort subdomain DNS cleanup failed', [
|
||||
'domain' => $host,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
DomainDnsRecord::query()
|
||||
->where('domain_id', $parentDomain->id)
|
||||
->where('name', $label)
|
||||
->where('type', 'A')
|
||||
->where('source', 'hosting_subdomain')
|
||||
->delete();
|
||||
}
|
||||
|
||||
private function availableHostingDomainsForUser(int $userId, HostingAccount $account): \Illuminate\Support\Collection
|
||||
{
|
||||
$linkedDomains = HostedSite::whereNotNull('domain')
|
||||
|
||||
@@ -209,7 +209,7 @@ class HostingProductController extends Controller
|
||||
$renewals ??= app(HostingRenewalCheckoutService::class);
|
||||
|
||||
$account->load(['product', 'user', 'node', 'sites']);
|
||||
$maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1;
|
||||
$maxDomains = $account->maxDomainsLimit();
|
||||
$freeEmailAllowance = $account->freeEmailAllowance();
|
||||
$mailboxes = $account->loadedMailboxes();
|
||||
$mailboxCount = $mailboxes->count();
|
||||
@@ -219,7 +219,7 @@ class HostingProductController extends Controller
|
||||
'account' => $account,
|
||||
'linkedSites' => $account->sites->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain])->values(),
|
||||
'maxDomains' => $maxDomains,
|
||||
'canLinkMoreDomains' => $account->sites->count() < $maxDomains,
|
||||
'canLinkMoreDomains' => $account->canAddDomain(),
|
||||
'ownedDomains' => collect($this->getUserDomains($request->user())),
|
||||
'emailUsage' => [
|
||||
'mailbox_count' => $mailboxCount,
|
||||
|
||||
@@ -311,6 +311,61 @@ class HostingAccount extends Model
|
||||
return max($included, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain slot limit from resource limits / product. -1 means unlimited.
|
||||
*/
|
||||
public function maxDomainsLimit(): int
|
||||
{
|
||||
$limit = data_get($this->resource_limits, 'max_domains');
|
||||
if ($limit === null) {
|
||||
$limit = $this->product?->max_domains;
|
||||
}
|
||||
|
||||
if ($limit === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return (int) $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subdomain slot limit: max_domains × 5. Unlimited domains → unlimited subdomains.
|
||||
*/
|
||||
public function maxSubdomainsLimit(): int
|
||||
{
|
||||
$maxDomains = $this->maxDomainsLimit();
|
||||
|
||||
return $maxDomains === -1 ? -1 : max(0, $maxDomains) * 5;
|
||||
}
|
||||
|
||||
public function domainSitesCount(): int
|
||||
{
|
||||
return $this->sites()
|
||||
->whereIn('type', ['primary', 'addon'])
|
||||
->count();
|
||||
}
|
||||
|
||||
public function subdomainSitesCount(): int
|
||||
{
|
||||
return $this->sites()
|
||||
->where('type', 'subdomain')
|
||||
->count();
|
||||
}
|
||||
|
||||
public function canAddDomain(): bool
|
||||
{
|
||||
$limit = $this->maxDomainsLimit();
|
||||
|
||||
return $limit === -1 || $this->domainSitesCount() < $limit;
|
||||
}
|
||||
|
||||
public function canAddSubdomain(): bool
|
||||
{
|
||||
$limit = $this->maxSubdomainsLimit();
|
||||
|
||||
return $limit === -1 || $this->subdomainSitesCount() < $limit;
|
||||
}
|
||||
|
||||
public function paidMailboxCount(): int
|
||||
{
|
||||
if (! static::tracksLocalMailboxes()) {
|
||||
|
||||
@@ -151,6 +151,40 @@ class PowerDnsClient
|
||||
return ['status' => 'updated', 'count' => count($rrsets)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete specific name+type rrsets from an existing zone (best-effort).
|
||||
*
|
||||
* @param string $zoneName
|
||||
* @param array<int, array{name: string, type?: string}> $records
|
||||
*/
|
||||
public function deleteRecords(string $zoneName, array $records): array
|
||||
{
|
||||
if ($records === []) {
|
||||
return ['status' => 'noop'];
|
||||
}
|
||||
|
||||
$zoneName = rtrim($zoneName, '.').'.';
|
||||
|
||||
$rrsets = array_map(function (array $record): array {
|
||||
return [
|
||||
'name' => rtrim($record['name'], '.').'.',
|
||||
'type' => strtoupper($record['type'] ?? 'A'),
|
||||
'changetype' => 'DELETE',
|
||||
'records' => [],
|
||||
];
|
||||
}, $records);
|
||||
|
||||
$response = $this->httpClient()->patch("servers/{$this->server()}/zones/{$zoneName}", [
|
||||
'rrsets' => $rrsets,
|
||||
]);
|
||||
|
||||
if ($response->failed() && $response->status() !== 404) {
|
||||
$response->throw();
|
||||
}
|
||||
|
||||
return ['status' => 'deleted', 'count' => count($rrsets)];
|
||||
}
|
||||
|
||||
public function deleteZone(Domain $domain): void
|
||||
{
|
||||
$zoneName = $this->zoneName($domain);
|
||||
|
||||
@@ -25,6 +25,7 @@ class UserFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'public_id' => (string) Str::uuid(),
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
|
||||
@@ -435,7 +435,12 @@
|
||||
@endforeach
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-slate-400">
|
||||
{{ $linkedSites->count() }} of {{ $maxDomains }} domain {{ $maxDomains === 1 ? 'slot' : 'slots' }} in use.
|
||||
@if ($maxDomains === -1)
|
||||
{{ $account->domainSitesCount() }} domain {{ $account->domainSitesCount() === 1 ? 'slot' : 'slots' }} in use (unlimited).
|
||||
@else
|
||||
{{ $account->domainSitesCount() }} of {{ $maxDomains }} domain {{ $maxDomains === 1 ? 'slot' : 'slots' }} in use.
|
||||
@endif
|
||||
· {{ $account->subdomainSitesCount() }}{{ $account->maxSubdomainsLimit() === -1 ? '' : ' of '.$account->maxSubdomainsLimit() }} subdomain {{ $account->subdomainSitesCount() === 1 ? 'slot' : 'slots' }} in use.
|
||||
</p>
|
||||
@elseif ($account->primary_domain)
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
@@ -24,10 +24,38 @@
|
||||
: ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill');
|
||||
$selectedOwnedDomain = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
|
||||
$externalFallbackDomain = ! in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
|
||||
$parentOptions = collect($parentSites ?? [])->values();
|
||||
$defaultParentId = (int) old('parent_site_id', $parentOptions->first()?->id ?? 0);
|
||||
$defaultParentHost = optional($parentOptions->firstWhere('id', $defaultParentId))->domain
|
||||
?? optional($parentOptions->first())->domain
|
||||
?? '';
|
||||
@endphp
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4 sm:p-5">
|
||||
<div class="flex flex-wrap gap-4 text-sm text-slate-600">
|
||||
<p>
|
||||
@if ($maxDomains === -1)
|
||||
<span class="font-semibold text-slate-900">{{ $domainSitesCount }}</span> domain {{ $domainSitesCount === 1 ? 'slot' : 'slots' }} in use (unlimited)
|
||||
@else
|
||||
<span class="font-semibold text-slate-900">{{ $domainSitesCount }} of {{ $maxDomains }}</span> domain {{ $maxDomains === 1 ? 'slot' : 'slots' }} in use
|
||||
@endif
|
||||
</p>
|
||||
<p class="text-slate-300">|</p>
|
||||
<p>
|
||||
@if ($maxSubdomains === -1)
|
||||
<span class="font-semibold text-slate-900">{{ $subdomainSitesCount }}</span> subdomain {{ $subdomainSitesCount === 1 ? 'slot' : 'slots' }} in use (unlimited)
|
||||
@else
|
||||
<span class="font-semibold text-slate-900">{{ $subdomainSitesCount }} of {{ $maxSubdomains }}</span> subdomain {{ $maxSubdomains === 1 ? 'slot' : 'slots' }} in use
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold text-slate-900 mb-4">Add Domain</h3>
|
||||
@if (! $canAddDomain)
|
||||
<p class="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-4">Domain slot limit reached. Upgrade your plan or remove an unused domain.</p>
|
||||
@endif
|
||||
<form action="{{ route('hosting.panel.domains.add', $account) }}" method="POST" class="space-y-5"
|
||||
x-data="ladillDomainSourceForm({
|
||||
domainSource: @js($initialDomainSource),
|
||||
@@ -45,13 +73,80 @@
|
||||
show-document-root
|
||||
/>
|
||||
|
||||
<button type="submit" class="btn-primary">
|
||||
<button type="submit" class="btn-primary" @disabled(! $canAddDomain)>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Add Domain
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold text-slate-900 mb-1">Create subdomain</h3>
|
||||
<p class="text-sm text-slate-500 mb-4">Create a hostname under one of your attached domains (up to 5× your domain slot limit).</p>
|
||||
|
||||
@if ($parentOptions->isEmpty())
|
||||
<p class="text-sm text-slate-500">Add a primary or addon domain before creating subdomains.</p>
|
||||
@else
|
||||
@if (! $canAddSubdomain)
|
||||
<p class="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-4">Subdomain slot limit reached.</p>
|
||||
@endif
|
||||
<form action="{{ route('hosting.panel.domains.subdomains.add', $account) }}" method="POST" class="space-y-4"
|
||||
x-data="{
|
||||
parentId: @js((string) $defaultParentId),
|
||||
parents: @js($parentOptions->mapWithKeys(fn ($s) => [(string) $s->id => $s->domain])->all()),
|
||||
label: @js(old('subdomain', '')),
|
||||
get preview() {
|
||||
const parent = this.parents[this.parentId] || '';
|
||||
const label = (this.label || '').toLowerCase().trim();
|
||||
return label && parent ? (label + '.' + parent) : parent;
|
||||
}
|
||||
}">
|
||||
@csrf
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="parent_site_id" class="mb-1 block text-xs font-medium text-slate-600">Parent domain</label>
|
||||
<select id="parent_site_id" name="parent_site_id" x-model="parentId" required
|
||||
class="w-full rounded-lg border-slate-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
@foreach ($parentOptions as $parent)
|
||||
<option value="{{ $parent->id }}" @selected((int) $defaultParentId === (int) $parent->id)>
|
||||
{{ $parent->domain }} ({{ $parent->type }})
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('parent_site_id') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="subdomain" class="mb-1 block text-xs font-medium text-slate-600">Subdomain label</label>
|
||||
<input id="subdomain" type="text" name="subdomain" x-model="label"
|
||||
value="{{ old('subdomain') }}"
|
||||
placeholder="blog"
|
||||
pattern="[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"
|
||||
maxlength="63"
|
||||
required
|
||||
class="w-full rounded-lg border-slate-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
@error('subdomain') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-slate-600">Hostname preview</label>
|
||||
<p class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm font-mono text-slate-800" x-text="preview || '—'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="subdomain_document_root" class="mb-1 block text-xs font-medium text-slate-600">Document root (optional)</label>
|
||||
<input id="subdomain_document_root" type="text" name="document_root"
|
||||
value="{{ old('document_root') }}"
|
||||
placeholder="public_html/blog.example.com"
|
||||
class="w-full rounded-lg border-slate-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-xs text-slate-500">Relative to /home/{{ $account->username }}/. Leave blank to use public_html/<hostname>.</p>
|
||||
@error('document_root') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" @disabled(! $canAddSubdomain)>
|
||||
Create subdomain
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-200">
|
||||
<h3 class="text-base font-semibold text-slate-900">Your Domains</h3>
|
||||
@@ -61,13 +156,14 @@
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Domain</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Type</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Document Root</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Status</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
@foreach($account->sites as $site)
|
||||
@foreach ($account->sites->sortBy(fn ($site) => [match ($site->type) { 'primary' => 0, 'addon' => 1, default => 2 }, $site->domain]) as $site)
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -77,6 +173,18 @@
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@php
|
||||
$typeStyles = [
|
||||
'primary' => 'bg-indigo-100 text-indigo-800',
|
||||
'addon' => 'bg-slate-100 text-slate-800',
|
||||
'subdomain' => 'bg-sky-100 text-sky-800',
|
||||
];
|
||||
@endphp
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {{ $typeStyles[$site->type] ?? 'bg-slate-100 text-slate-800' }}">
|
||||
{{ ucfirst($site->type) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm font-mono text-slate-600">{{ $site->document_root }}</span>
|
||||
</td>
|
||||
@@ -112,7 +220,7 @@
|
||||
@else
|
||||
<div class="px-6 py-12 text-center">
|
||||
@include('components.icons.domain-globe', ['class' => 'mx-auto h-12 w-12 text-slate-300'])
|
||||
<p class="mt-4 text-sm text-slate-500">No addon domains yet. Add one above.</p>
|
||||
<p class="mt-4 text-sm text-slate-500">No domains yet. Add one above.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if ($errors->any())
|
||||
@if (isset($errors) && $errors->any())
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
@foreach ($errors->all() as $err)
|
||||
|
||||
@@ -93,6 +93,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () use ($servers
|
||||
Route::post('/databases/{database}/reset-password', [HostingPanelController::class, 'resetDatabasePassword'])->name('hosting.panel.databases.reset-password');
|
||||
Route::get('/domains', [HostingPanelController::class, 'domains'])->name('hosting.panel.domains');
|
||||
Route::post('/domains', [HostingPanelController::class, 'addDomain'])->name('hosting.panel.domains.add');
|
||||
Route::post('/domains/subdomains', [HostingPanelController::class, 'addSubdomain'])->name('hosting.panel.domains.subdomains.add');
|
||||
Route::delete('/domains/{site}', [HostingPanelController::class, 'removeDomain'])->name('hosting.panel.domains.remove');
|
||||
Route::get('/settings', [HostingPanelController::class, 'settings'])->name('hosting.panel.settings');
|
||||
Route::post('/settings/password', [HostingPanelController::class, 'changePassword'])->name('hosting.panel.settings.password');
|
||||
|
||||
@@ -15,12 +15,19 @@ use App\Services\Domain\DomainVerificationService;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Tests\TestCase;
|
||||
|
||||
class HostingPanelDomainLinkingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Bus::fake();
|
||||
}
|
||||
|
||||
public function test_add_domain_uses_automated_nameserver_onboarding_by_default(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
@@ -67,6 +74,7 @@ class HostingPanelDomainLinkingTest extends TestCase
|
||||
]);
|
||||
|
||||
$verification = \Mockery::mock(DomainVerificationService::class);
|
||||
$verification->shouldReceive('reprovision')->once();
|
||||
$verification->shouldNotReceive('prepareNameserverZone');
|
||||
$verification->shouldNotReceive('prepareManualDnsPack');
|
||||
|
||||
@@ -169,6 +177,7 @@ class HostingPanelDomainLinkingTest extends TestCase
|
||||
$this->assertStringContainsString('primary.example.com', $html);
|
||||
$this->assertStringContainsString('linked-example.com', $html);
|
||||
$this->assertStringContainsString('2 of 3 domain slots in use.', $html);
|
||||
$this->assertStringContainsString('0 of 15 subdomain slots in use.', $html);
|
||||
}
|
||||
|
||||
public function test_add_domain_does_not_restore_soft_deleted_site_from_another_account(): void
|
||||
@@ -260,4 +269,245 @@ class HostingPanelDomainLinkingTest extends TestCase
|
||||
'domain' => 'linked-example.com',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_add_subdomain_creates_hosted_site_under_parent_domain(): void
|
||||
{
|
||||
[$user, $account, $parent] = $this->makeAccountWithPrimary(maxDomains: 1, withManagedDomain: true);
|
||||
|
||||
$provider = \Mockery::mock(SharedNodeProvider::class);
|
||||
$provider->shouldReceive('addSite')
|
||||
->once()
|
||||
->withArgs(fn (HostedSite $site): bool => $site->domain === 'blog.primary.example.com' && $site->type === 'subdomain')
|
||||
->andReturn(['document_root' => '/home/acctuser/public_html/blog.primary.example.com', 'domain' => 'blog.primary.example.com']);
|
||||
|
||||
$pdns = \Mockery::mock(\App\Services\Dns\PowerDnsClient::class);
|
||||
$pdns->shouldReceive('upsertRecords')
|
||||
->once()
|
||||
->withArgs(function (string $zone, array $records) {
|
||||
return $zone === 'primary.example.com'
|
||||
&& ($records[0]['name'] ?? null) === 'blog.primary.example.com'
|
||||
&& ($records[0]['contents'][0] ?? null) === '127.0.0.1';
|
||||
})
|
||||
->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' => 'blog',
|
||||
], [], [], [
|
||||
'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,
|
||||
$this->app->make(\App\Services\Dns\PowerDnsClient::class),
|
||||
);
|
||||
|
||||
$this->assertSame(302, $response->getStatusCode());
|
||||
$this->assertDatabaseHas('hosted_sites', [
|
||||
'hosting_account_id' => $account->id,
|
||||
'domain' => 'blog.primary.example.com',
|
||||
'type' => 'subdomain',
|
||||
'domain_id' => $parent->domain_id,
|
||||
'document_root' => '/home/acctuser/public_html/blog.primary.example.com',
|
||||
]);
|
||||
$this->assertSame(1, $account->fresh()->subdomainSitesCount());
|
||||
$this->assertSame(5, $account->fresh()->maxSubdomainsLimit());
|
||||
}
|
||||
|
||||
public function test_subdomain_quota_is_max_domains_times_five(): void
|
||||
{
|
||||
[$user, $account, $parent] = $this->makeAccountWithPrimary(maxDomains: 1, withManagedDomain: false);
|
||||
|
||||
$provider = \Mockery::mock(SharedNodeProvider::class);
|
||||
$provider->shouldReceive('addSite')->times(5)->andReturnUsing(function (HostedSite $site) {
|
||||
return ['document_root' => $site->document_root, 'domain' => $site->domain];
|
||||
});
|
||||
$this->app->instance(SharedNodeProvider::class, $provider);
|
||||
$this->app->instance(\App\Services\Dns\PowerDnsClient::class, \Mockery::mock(\App\Services\Dns\PowerDnsClient::class));
|
||||
|
||||
$controller = $this->app->make(HostingPanelController::class);
|
||||
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$session = $this->app['session.store'];
|
||||
$session->start();
|
||||
$request = Request::create('/domains/subdomains', 'POST', [
|
||||
'parent_site_id' => $parent->id,
|
||||
'subdomain' => 'sub'.$i,
|
||||
]);
|
||||
$request->setUserResolver(fn (): User => $user);
|
||||
$request->setLaravelSession($session);
|
||||
|
||||
$response = $controller->addSubdomain($request, $account->fresh(), $this->app->make(\App\Services\Dns\PowerDnsClient::class));
|
||||
$this->assertSame(302, $response->getStatusCode());
|
||||
$this->assertNull($session->get('error'), 'sub'.$i.' should succeed');
|
||||
}
|
||||
|
||||
$this->assertSame(5, $account->fresh()->subdomainSitesCount());
|
||||
|
||||
$session = $this->app['session.store'];
|
||||
$session->start();
|
||||
$request = Request::create('/domains/subdomains', 'POST', [
|
||||
'parent_site_id' => $parent->id,
|
||||
'subdomain' => 'sub6',
|
||||
]);
|
||||
$request->setUserResolver(fn (): User => $user);
|
||||
$request->setLaravelSession($session);
|
||||
|
||||
$response = $controller->addSubdomain($request, $account->fresh(), $this->app->make(\App\Services\Dns\PowerDnsClient::class));
|
||||
$this->assertSame(302, $response->getStatusCode());
|
||||
$this->assertSame('You have reached the maximum number of subdomains (5).', $session->get('error'));
|
||||
$this->assertSame(5, $account->fresh()->subdomainSitesCount());
|
||||
}
|
||||
|
||||
public function test_addon_domain_cap_ignores_subdomains(): void
|
||||
{
|
||||
[$user, $account, $parent] = $this->makeAccountWithPrimary(maxDomains: 2, withManagedDomain: false);
|
||||
|
||||
HostedSite::create([
|
||||
'hosting_account_id' => $account->id,
|
||||
'domain' => 'blog.primary.example.com',
|
||||
'document_root' => '/home/acctuser/public_html/blog.primary.example.com',
|
||||
'type' => 'subdomain',
|
||||
'status' => 'active',
|
||||
'domain_id' => $parent->domain_id,
|
||||
]);
|
||||
|
||||
$provider = \Mockery::mock(SharedNodeProvider::class);
|
||||
$provider->shouldReceive('addSite')->once()->andReturn([
|
||||
'document_root' => '/home/acctuser/public_html/addon.example.com',
|
||||
'domain' => 'addon.example.com',
|
||||
]);
|
||||
$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('/domains', 'POST', ['domain' => 'addon.example.com']);
|
||||
$request->setUserResolver(fn (): User => $user);
|
||||
$request->setLaravelSession($session);
|
||||
|
||||
$response = $this->app->make(HostingPanelController::class)->addDomain(
|
||||
$request,
|
||||
$account->fresh(),
|
||||
$this->app->make(DomainDnsBlueprintService::class),
|
||||
$this->app->make(DomainVerificationService::class),
|
||||
);
|
||||
|
||||
$this->assertSame(302, $response->getStatusCode());
|
||||
$this->assertNull($session->get('error'));
|
||||
$this->assertDatabaseHas('hosted_sites', [
|
||||
'hosting_account_id' => $account->id,
|
||||
'domain' => 'addon.example.com',
|
||||
'type' => 'addon',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_add_subdomain_rejects_reserved_and_invalid_labels(): void
|
||||
{
|
||||
[$user, $account, $parent] = $this->makeAccountWithPrimary(maxDomains: 1, withManagedDomain: false);
|
||||
$this->app->instance(SharedNodeProvider::class, \Mockery::mock(SharedNodeProvider::class));
|
||||
$this->app->instance(\App\Services\Dns\PowerDnsClient::class, \Mockery::mock(\App\Services\Dns\PowerDnsClient::class));
|
||||
$controller = $this->app->make(HostingPanelController::class);
|
||||
|
||||
$session = $this->app['session.store'];
|
||||
$session->start();
|
||||
$request = Request::create('/domains/subdomains', 'POST', [
|
||||
'parent_site_id' => $parent->id,
|
||||
'subdomain' => 'www',
|
||||
]);
|
||||
$request->setUserResolver(fn (): User => $user);
|
||||
$request->setLaravelSession($session);
|
||||
$controller->addSubdomain($request, $account, $this->app->make(\App\Services\Dns\PowerDnsClient::class));
|
||||
$this->assertSame('The subdomain "www" is reserved.', $session->get('error'));
|
||||
|
||||
$this->expectException(\Illuminate\Validation\ValidationException::class);
|
||||
$session = $this->app['session.store'];
|
||||
$session->start();
|
||||
$request = Request::create('/domains/subdomains', 'POST', [
|
||||
'parent_site_id' => $parent->id,
|
||||
'subdomain' => 'bad.label',
|
||||
]);
|
||||
$request->setUserResolver(fn (): User => $user);
|
||||
$request->setLaravelSession($session);
|
||||
$controller->addSubdomain($request, $account, $this->app->make(\App\Services\Dns\PowerDnsClient::class));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: User, 1: HostingAccount, 2: HostedSite}
|
||||
*/
|
||||
private function makeAccountWithPrimary(int $maxDomains, bool $withManagedDomain): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$product = HostingProduct::create([
|
||||
'name' => 'Hosting Plan',
|
||||
'slug' => 'hosting-plan-'.uniqid(),
|
||||
'category' => 'shared',
|
||||
'type' => 'multi_domain',
|
||||
'price_monthly' => 10,
|
||||
'max_domains' => $maxDomains,
|
||||
]);
|
||||
|
||||
$node = HostingNode::create([
|
||||
'name' => 'Local Node',
|
||||
'hostname' => 'local-node-'.uniqid(),
|
||||
'ip_address' => '127.0.0.1',
|
||||
'type' => 'shared',
|
||||
'provider' => 'local',
|
||||
'status' => 'active',
|
||||
'ssh_port' => '22',
|
||||
]);
|
||||
|
||||
$account = HostingAccount::unguarded(function () use ($user, $node, $product, $maxDomains) {
|
||||
return HostingAccount::create([
|
||||
'user_id' => $user->id,
|
||||
'hosting_product_id' => $product->id,
|
||||
'hosting_node_id' => $node->id,
|
||||
'username' => 'acctuser',
|
||||
'primary_domain' => 'primary.example.com',
|
||||
'type' => 'shared',
|
||||
'status' => 'active',
|
||||
'php_version' => '8.2',
|
||||
'resource_limits' => ['max_domains' => $maxDomains],
|
||||
]);
|
||||
});
|
||||
|
||||
$domain = null;
|
||||
if ($withManagedDomain) {
|
||||
$domain = Domain::create([
|
||||
'user_id' => $user->id,
|
||||
'hosting_account_id' => $account->id,
|
||||
'host' => 'primary.example.com',
|
||||
'type' => 'custom',
|
||||
'source' => 'hosting',
|
||||
'onboarding_mode' => Domain::MODE_NS_AUTO,
|
||||
'status' => 'verified',
|
||||
'onboarding_state' => Domain::STATE_ACTIVE,
|
||||
'dns_mode' => 'managed',
|
||||
]);
|
||||
}
|
||||
|
||||
$parent = HostedSite::create([
|
||||
'hosting_account_id' => $account->id,
|
||||
'domain_id' => $domain?->id,
|
||||
'domain' => 'primary.example.com',
|
||||
'document_root' => '/home/acctuser/public_html',
|
||||
'type' => 'primary',
|
||||
'status' => 'active',
|
||||
'ssl_enabled' => true,
|
||||
]);
|
||||
|
||||
return [$user, $account->fresh(['sites', 'product', 'node']), $parent->fresh()];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user