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\AppInstallation;
|
||||||
use App\Models\CustomerHostingOrder;
|
use App\Models\CustomerHostingOrder;
|
||||||
use App\Models\Domain;
|
use App\Models\Domain;
|
||||||
|
use App\Models\DomainDnsRecord;
|
||||||
use App\Models\HostedDatabase;
|
use App\Models\HostedDatabase;
|
||||||
use App\Models\HostedSite;
|
use App\Models\HostedSite;
|
||||||
use App\Models\HostingAccount;
|
use App\Models\HostingAccount;
|
||||||
@@ -15,6 +16,7 @@ use App\Models\HostingOrder;
|
|||||||
use App\Models\RcServiceOrder;
|
use App\Models\RcServiceOrder;
|
||||||
use App\Models\Website;
|
use App\Models\Website;
|
||||||
use App\Models\WebsiteMember;
|
use App\Models\WebsiteMember;
|
||||||
|
use App\Services\Dns\PowerDnsClient;
|
||||||
use App\Services\Domain\DomainDnsBlueprintService;
|
use App\Services\Domain\DomainDnsBlueprintService;
|
||||||
use App\Services\Domain\DomainVerificationService;
|
use App\Services\Domain\DomainVerificationService;
|
||||||
use App\Services\Hosting\Contracts\PanelRuntimeInterface;
|
use App\Services\Hosting\Contracts\PanelRuntimeInterface;
|
||||||
@@ -1074,10 +1076,21 @@ class HostingPanelController extends Controller
|
|||||||
$account->load(['node', 'product', 'sites']);
|
$account->load(['node', 'product', 'sites']);
|
||||||
|
|
||||||
$ownedDomains = $this->availableHostingDomainsForUser($request->user()->id, $account);
|
$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', [
|
return view('hosting.panel.domains', [
|
||||||
'account' => $account,
|
'account' => $account,
|
||||||
'ownedDomains' => $ownedDomains,
|
'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);
|
$this->authorizeForRequestUser($request, 'manageDomains', $account);
|
||||||
$account->load(['node', 'product', 'sites']);
|
$account->load(['node', 'product', 'sites']);
|
||||||
|
|
||||||
$maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1;
|
if (! $account->canAddDomain()) {
|
||||||
if ($account->sites->count() >= $maxDomains) {
|
$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()) {
|
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');
|
$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
|
public function removeDomain(Request $request, HostingAccount $account, HostedSite $site): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_unless((int) $account->user_id === (int) $request->user()->id, 403);
|
abort_unless((int) $account->user_id === (int) $request->user()->id, 403);
|
||||||
$this->ensureSiteBelongsToAccount($site, $account);
|
$this->ensureSiteBelongsToAccount($site, $account);
|
||||||
|
|
||||||
$account->load(['node']);
|
$account->load(['node']);
|
||||||
|
$site->loadMissing('domain');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->runtimeFor($account)->removeSite($site);
|
$this->runtimeFor($account)->removeSite($site);
|
||||||
|
$this->cleanupSubdomainDns($site);
|
||||||
$site->delete();
|
$site->delete();
|
||||||
|
|
||||||
return back()->with('success', 'Domain removed successfully.');
|
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
|
private function availableHostingDomainsForUser(int $userId, HostingAccount $account): \Illuminate\Support\Collection
|
||||||
{
|
{
|
||||||
$linkedDomains = HostedSite::whereNotNull('domain')
|
$linkedDomains = HostedSite::whereNotNull('domain')
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ class HostingProductController extends Controller
|
|||||||
$renewals ??= app(HostingRenewalCheckoutService::class);
|
$renewals ??= app(HostingRenewalCheckoutService::class);
|
||||||
|
|
||||||
$account->load(['product', 'user', 'node', 'sites']);
|
$account->load(['product', 'user', 'node', 'sites']);
|
||||||
$maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1;
|
$maxDomains = $account->maxDomainsLimit();
|
||||||
$freeEmailAllowance = $account->freeEmailAllowance();
|
$freeEmailAllowance = $account->freeEmailAllowance();
|
||||||
$mailboxes = $account->loadedMailboxes();
|
$mailboxes = $account->loadedMailboxes();
|
||||||
$mailboxCount = $mailboxes->count();
|
$mailboxCount = $mailboxes->count();
|
||||||
@@ -219,7 +219,7 @@ class HostingProductController extends Controller
|
|||||||
'account' => $account,
|
'account' => $account,
|
||||||
'linkedSites' => $account->sites->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain])->values(),
|
'linkedSites' => $account->sites->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain])->values(),
|
||||||
'maxDomains' => $maxDomains,
|
'maxDomains' => $maxDomains,
|
||||||
'canLinkMoreDomains' => $account->sites->count() < $maxDomains,
|
'canLinkMoreDomains' => $account->canAddDomain(),
|
||||||
'ownedDomains' => collect($this->getUserDomains($request->user())),
|
'ownedDomains' => collect($this->getUserDomains($request->user())),
|
||||||
'emailUsage' => [
|
'emailUsage' => [
|
||||||
'mailbox_count' => $mailboxCount,
|
'mailbox_count' => $mailboxCount,
|
||||||
|
|||||||
@@ -311,6 +311,61 @@ class HostingAccount extends Model
|
|||||||
return max($included, 0);
|
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
|
public function paidMailboxCount(): int
|
||||||
{
|
{
|
||||||
if (! static::tracksLocalMailboxes()) {
|
if (! static::tracksLocalMailboxes()) {
|
||||||
|
|||||||
@@ -151,6 +151,40 @@ class PowerDnsClient
|
|||||||
return ['status' => 'updated', 'count' => count($rrsets)];
|
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
|
public function deleteZone(Domain $domain): void
|
||||||
{
|
{
|
||||||
$zoneName = $this->zoneName($domain);
|
$zoneName = $this->zoneName($domain);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class UserFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'public_id' => (string) Str::uuid(),
|
||||||
'name' => fake()->name(),
|
'name' => fake()->name(),
|
||||||
'email' => fake()->unique()->safeEmail(),
|
'email' => fake()->unique()->safeEmail(),
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
|
|||||||
@@ -435,7 +435,12 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 text-xs text-slate-400">
|
<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>
|
</p>
|
||||||
@elseif ($account->primary_domain)
|
@elseif ($account->primary_domain)
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|||||||
@@ -24,10 +24,38 @@
|
|||||||
: ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill');
|
: ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill');
|
||||||
$selectedOwnedDomain = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
|
$selectedOwnedDomain = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
|
||||||
$externalFallbackDomain = ! 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
|
@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">
|
<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>
|
<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"
|
<form action="{{ route('hosting.panel.domains.add', $account) }}" method="POST" class="space-y-5"
|
||||||
x-data="ladillDomainSourceForm({
|
x-data="ladillDomainSourceForm({
|
||||||
domainSource: @js($initialDomainSource),
|
domainSource: @js($initialDomainSource),
|
||||||
@@ -45,13 +73,80 @@
|
|||||||
show-document-root
|
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>
|
<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
|
Add Domain
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||||
<div class="px-6 py-4 border-b border-slate-200">
|
<div class="px-6 py-4 border-b border-slate-200">
|
||||||
<h3 class="text-base font-semibold text-slate-900">Your Domains</h3>
|
<h3 class="text-base font-semibold text-slate-900">Your Domains</h3>
|
||||||
@@ -61,13 +156,14 @@
|
|||||||
<thead class="bg-slate-50">
|
<thead class="bg-slate-50">
|
||||||
<tr>
|
<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">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">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-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>
|
<th class="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-200">
|
<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">
|
<tr class="hover:bg-slate-50">
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -77,6 +173,18 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<span class="text-sm font-mono text-slate-600">{{ $site->document_root }}</span>
|
<span class="text-sm font-mono text-slate-600">{{ $site->document_root }}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -112,7 +220,7 @@
|
|||||||
@else
|
@else
|
||||||
<div class="px-6 py-12 text-center">
|
<div class="px-6 py-12 text-center">
|
||||||
@include('components.icons.domain-globe', ['class' => 'mx-auto h-12 w-12 text-slate-300'])
|
@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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
@endif
|
@endif
|
||||||
@endforeach
|
@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">
|
<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">
|
<ul class="list-disc space-y-1 pl-5">
|
||||||
@foreach ($errors->all() as $err)
|
@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::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::get('/domains', [HostingPanelController::class, 'domains'])->name('hosting.panel.domains');
|
||||||
Route::post('/domains', [HostingPanelController::class, 'addDomain'])->name('hosting.panel.domains.add');
|
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::delete('/domains/{site}', [HostingPanelController::class, 'removeDomain'])->name('hosting.panel.domains.remove');
|
||||||
Route::get('/settings', [HostingPanelController::class, 'settings'])->name('hosting.panel.settings');
|
Route::get('/settings', [HostingPanelController::class, 'settings'])->name('hosting.panel.settings');
|
||||||
Route::post('/settings/password', [HostingPanelController::class, 'changePassword'])->name('hosting.panel.settings.password');
|
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 App\Services\Hosting\Providers\SharedNodeProvider;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class HostingPanelDomainLinkingTest extends TestCase
|
class HostingPanelDomainLinkingTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Bus::fake();
|
||||||
|
}
|
||||||
|
|
||||||
public function test_add_domain_uses_automated_nameserver_onboarding_by_default(): void
|
public function test_add_domain_uses_automated_nameserver_onboarding_by_default(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@@ -67,6 +74,7 @@ class HostingPanelDomainLinkingTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$verification = \Mockery::mock(DomainVerificationService::class);
|
$verification = \Mockery::mock(DomainVerificationService::class);
|
||||||
|
$verification->shouldReceive('reprovision')->once();
|
||||||
$verification->shouldNotReceive('prepareNameserverZone');
|
$verification->shouldNotReceive('prepareNameserverZone');
|
||||||
$verification->shouldNotReceive('prepareManualDnsPack');
|
$verification->shouldNotReceive('prepareManualDnsPack');
|
||||||
|
|
||||||
@@ -169,6 +177,7 @@ class HostingPanelDomainLinkingTest extends TestCase
|
|||||||
$this->assertStringContainsString('primary.example.com', $html);
|
$this->assertStringContainsString('primary.example.com', $html);
|
||||||
$this->assertStringContainsString('linked-example.com', $html);
|
$this->assertStringContainsString('linked-example.com', $html);
|
||||||
$this->assertStringContainsString('2 of 3 domain slots in use.', $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
|
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',
|
'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