diff --git a/app/Http/Controllers/Hosting/HostingPanelController.php b/app/Http/Controllers/Hosting/HostingPanelController.php index b2887ca..fbe2238 100644 --- a/app/Http/Controllers/Hosting/HostingPanelController.php +++ b/app/Http/Controllers/Hosting/HostingPanelController.php @@ -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') diff --git a/app/Http/Controllers/Hosting/HostingProductController.php b/app/Http/Controllers/Hosting/HostingProductController.php index d808b48..eddbf18 100644 --- a/app/Http/Controllers/Hosting/HostingProductController.php +++ b/app/Http/Controllers/Hosting/HostingProductController.php @@ -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, diff --git a/app/Models/HostingAccount.php b/app/Models/HostingAccount.php index aef6767..f99dd31 100644 --- a/app/Models/HostingAccount.php +++ b/app/Models/HostingAccount.php @@ -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()) { diff --git a/app/Services/Dns/PowerDnsClient.php b/app/Services/Dns/PowerDnsClient.php index 5391b95..cf4b8e4 100644 --- a/app/Services/Dns/PowerDnsClient.php +++ b/app/Services/Dns/PowerDnsClient.php @@ -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 $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); diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index c4ceb07..494cb05 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -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(), diff --git a/resources/views/hosting/account.blade.php b/resources/views/hosting/account.blade.php index e1a2f4d..5327229 100644 --- a/resources/views/hosting/account.blade.php +++ b/resources/views/hosting/account.blade.php @@ -435,7 +435,12 @@ @endforeach

- {{ $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.

@elseif ($account->primary_domain)
diff --git a/resources/views/hosting/panel/domains.blade.php b/resources/views/hosting/panel/domains.blade.php index c6bdff0..244b1f1 100644 --- a/resources/views/hosting/panel/domains.blade.php +++ b/resources/views/hosting/panel/domains.blade.php @@ -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 +
+
+

+ @if ($maxDomains === -1) + {{ $domainSitesCount }} domain {{ $domainSitesCount === 1 ? 'slot' : 'slots' }} in use (unlimited) + @else + {{ $domainSitesCount }} of {{ $maxDomains }} domain {{ $maxDomains === 1 ? 'slot' : 'slots' }} in use + @endif +

+

|

+

+ @if ($maxSubdomains === -1) + {{ $subdomainSitesCount }} subdomain {{ $subdomainSitesCount === 1 ? 'slot' : 'slots' }} in use (unlimited) + @else + {{ $subdomainSitesCount }} of {{ $maxSubdomains }} subdomain {{ $maxSubdomains === 1 ? 'slot' : 'slots' }} in use + @endif +

+
+
+

Add Domain

+ @if (! $canAddDomain) +

Domain slot limit reached. Upgrade your plan or remove an unused domain.

+ @endif
+
+
+

Create subdomain

+

Create a hostname under one of your attached domains (up to 5× your domain slot limit).

+ + @if ($parentOptions->isEmpty()) +

Add a primary or addon domain before creating subdomains.

+ @else + @if (! $canAddSubdomain) +

Subdomain slot limit reached.

+ @endif +
+ @csrf +
+
+ + + @error('parent_site_id')

{{ $message }}

@enderror +
+
+ + + @error('subdomain')

{{ $message }}

@enderror +
+
+
+ +

+
+
+ + +

Relative to /home/{{ $account->username }}/. Leave blank to use public_html/<hostname>.

+ @error('document_root')

{{ $message }}

@enderror +
+ +
+ @endif +
+

Your Domains

@@ -61,13 +156,14 @@ Domain + Type Document Root Status Actions - @foreach($account->sites as $site) + @foreach ($account->sites->sortBy(fn ($site) => [match ($site->type) { 'primary' => 0, 'addon' => 1, default => 2 }, $site->domain]) as $site)
@@ -77,6 +173,18 @@
+ + @php + $typeStyles = [ + 'primary' => 'bg-indigo-100 text-indigo-800', + 'addon' => 'bg-slate-100 text-slate-800', + 'subdomain' => 'bg-sky-100 text-sky-800', + ]; + @endphp + + {{ ucfirst($site->type) }} + + {{ $site->document_root }} @@ -112,7 +220,7 @@ @else
@include('components.icons.domain-globe', ['class' => 'mx-auto h-12 w-12 text-slate-300']) -

No addon domains yet. Add one above.

+

No domains yet. Add one above.

@endif
diff --git a/resources/views/partials/flash.blade.php b/resources/views/partials/flash.blade.php index 8116c8e..aae4334 100644 --- a/resources/views/partials/flash.blade.php +++ b/resources/views/partials/flash.blade.php @@ -11,7 +11,7 @@ @endif @endforeach -@if ($errors->any()) +@if (isset($errors) && $errors->any())
    @foreach ($errors->all() as $err) diff --git a/routes/web.php b/routes/web.php index 6099137..a0916a6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/HostingPanelDomainLinkingTest.php b/tests/Feature/HostingPanelDomainLinkingTest.php index 768d35a..2c6b240 100644 --- a/tests/Feature/HostingPanelDomainLinkingTest.php +++ b/tests/Feature/HostingPanelDomainLinkingTest.php @@ -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()]; + } }