Add first-class hosting subdomain create/attach in Domains panel.
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:
isaacclad
2026-07-13 12:08:41 +00:00
co-authored by Cursor
parent c12c1cf7c3
commit 460edb8719
10 changed files with 723 additions and 11 deletions
@@ -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,
+55
View File
@@ -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()) {
+34
View File
@@ -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);
+1
View File
@@ -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(),
+6 -1
View File
@@ -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">
+111 -3
View File
@@ -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/&lt;hostname&gt;.</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>
+1 -1
View File
@@ -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)
+1
View File
@@ -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()];
}
}