Files
ladill-hosting/app/Services/Hosting/AdminHostingAssignmentService.php
isaaccladandCursor 136cf8b719
Deploy Ladill Hosting / deploy (push) Successful in 47s
Fix subdomain DNS messaging for Ladill nameservers and retire pending-setup primaries.
Treat ns_auto domains as managed DNS, publish subdomain A records via PowerDNS without failing the UX when the local DNS registry is missing, and promote real linked domains over pending-setup.local placeholders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 16:47:50 +00:00

248 lines
8.9 KiB
PHP

<?php
namespace App\Services\Hosting;
use App\Jobs\ProvisionHostingAccountJob;
use App\Models\HostingAccount;
use App\Models\HostingProduct;
use App\Models\User;
use Carbon\CarbonInterface;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class AdminHostingAssignmentService
{
public function __construct(
private NodeCapacityService $nodeCapacity,
private HostingResourcePolicyService $resourcePolicy,
) {}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
public function assign(User $owner, array $input): array
{
$product = HostingProduct::query()->findOrFail((int) $input['hosting_product_id']);
if (! $product->is_active) {
throw ValidationException::withMessages([
'hosting_product_id' => 'Selected hosting product is not active.',
]);
}
$username = isset($input['username']) && $input['username'] !== ''
? (string) $input['username']
: $this->generateUsername($owner);
if (HostingAccount::query()->where('username', $username)->exists()) {
throw ValidationException::withMessages([
'username' => 'This username is already taken.',
]);
}
$primaryDomain = isset($input['primary_domain']) && $input['primary_domain'] !== ''
? strtolower(trim((string) $input['primary_domain']))
: null;
if ($primaryDomain !== null && PlaceholderPrimaryDomainService::isPlaceholderDomain($primaryDomain)) {
$primaryDomain = null;
}
$accountType = match ($product->type) {
'single_domain', 'multi_domain', 'wordpress' => HostingAccount::TYPE_SHARED,
'vps' => HostingAccount::TYPE_VPS,
'dedicated' => HostingAccount::TYPE_DEDICATED,
'email_standalone' => HostingAccount::TYPE_SHARED,
default => HostingAccount::TYPE_SHARED,
};
$node = null;
$nodeId = null;
if ($accountType === HostingAccount::TYPE_SHARED) {
$node = $this->nodeCapacity->findAvailableNodeForProduct($product);
$nodeId = $node?->id;
}
$resourceLimits = $this->resourcePolicy->defaultLimitsForProduct($product);
$durationMonths = (int) $input['duration_months'];
$hostingAccount = HostingAccount::create([
'user_id' => $owner->id,
'hosting_product_id' => $product->id,
'hosting_node_id' => $nodeId,
'username' => $username,
'primary_domain' => $primaryDomain,
'type' => $accountType,
'status' => HostingAccount::STATUS_PENDING,
'allocated_disk_gb' => (int) ($product->disk_gb ?? 0),
'cpu_limit_percent' => $resourceLimits['cpu_limit_percent'],
'memory_limit_mb' => $resourceLimits['memory_limit_mb'],
'process_limit' => $resourceLimits['process_limit'],
'io_limit_mb' => $resourceLimits['io_limit_mb'],
'inode_limit' => $resourceLimits['inode_limit'],
'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE,
'expires_at' => $this->expiresAtFromDuration($durationMonths),
'resource_limits' => array_merge([
'disk_gb' => (int) ($product->disk_gb ?? 0),
'max_domains' => $product->max_domains,
'max_databases' => $product->max_databases,
], $resourceLimits),
'metadata' => [
'assigned_by_admin' => $input['assigned_by_admin'] ?? null,
'assigned_at' => now()->toISOString(),
'assigned_duration_months' => $durationMonths,
'notes' => $input['notes'] ?? null,
],
]);
if ($node) {
ProvisionHostingAccountJob::dispatch($hostingAccount->id);
return [
'account' => $this->serializeAccount($hostingAccount->fresh(['product'])),
'provisioning' => true,
'message' => "Hosting product '{$product->name}' assigned. Provisioning is in progress.",
];
}
$hostingAccount->update([
'status' => HostingAccount::STATUS_ACTIVE,
'provisioned_at' => now(),
]);
return [
'account' => $this->serializeAccount($hostingAccount->fresh(['product'])),
'provisioning' => false,
'message' => "Hosting product '{$product->name}' assigned.",
];
}
public function updateDuration(HostingAccount $account, int $durationMonths, ?int $adminId = null): array
{
$metadata = (array) ($account->metadata ?? []);
$metadata['assigned_duration_months'] = $durationMonths;
$metadata['duration_updated_by_admin'] = $adminId;
$metadata['duration_updated_at'] = now()->toISOString();
$account->update([
'expires_at' => $this->expiresAtFromDuration($durationMonths),
'metadata' => $metadata,
]);
return $this->serializeAccount($account->fresh(['product']));
}
public function renew(HostingAccount $account, ?int $adminId = null): array
{
if (! $account->canBeRenewed()) {
throw ValidationException::withMessages([
'account' => 'This hosting account does not have a configured renewal duration.',
]);
}
$months = $account->assignedDurationMonths();
$account->renew($months, [
'renewed_by_admin' => $adminId,
'renewed_at' => now()->toISOString(),
]);
return $this->serializeAccount($account->fresh(['product']));
}
public function unsuspend(HostingAccount $account, HostingResourcePolicyService $resourcePolicy): void
{
if (
$account->status !== HostingAccount::STATUS_SUSPENDED
&& $account->resource_status !== HostingAccount::RESOURCE_STATUS_SUSPENDED
) {
throw ValidationException::withMessages([
'account' => 'This hosting account is not suspended.',
]);
}
$resourcePolicy->unsuspendAccount($account, 'Manual unsuspension by admin.');
}
/**
* @return array<string, mixed>
*/
public function serializeAccount(HostingAccount $account): array
{
$product = $account->product;
return [
'id' => $account->id,
'username' => $account->username,
'primary_domain' => $account->primary_domain,
'type' => $account->type,
'status' => $account->status,
'resource_status' => $account->resource_status,
'expires_at' => $account->expires_at?->toIso8601String(),
'provisioned_at' => $account->provisioned_at?->toIso8601String(),
'metadata' => (array) ($account->metadata ?? []),
'product' => $product ? [
'id' => $product->id,
'name' => $product->name,
'type' => $product->type,
'slug' => $product->slug,
] : null,
];
}
/**
* @return list<array<string, mixed>>
*/
public function listProducts(): array
{
return HostingProduct::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
->get()
->map(fn (HostingProduct $product) => [
'id' => $product->id,
'name' => $product->name,
'type' => $product->type,
'slug' => $product->slug,
'display_currency' => $product->display_currency,
'display_price_monthly' => $product->display_price_monthly,
])
->values()
->all();
}
private function generateUsername(User $user): string
{
$baseUsername = strtolower(str_replace([' ', '.', '_'], '', $user->name));
$baseUsername = preg_replace('/[^a-z0-9]/', '', $baseUsername);
if (strlen($baseUsername) < 3) {
$baseUsername = strtolower(substr($user->email, 0, str_contains($user->email, '@') ? strpos($user->email, '@') : strlen($user->email)));
$baseUsername = preg_replace('/[^a-z0-9]/', '', $baseUsername);
}
if (strlen($baseUsername) < 3) {
$baseUsername = 'user'.$user->id;
}
$username = substr($baseUsername, 0, 16);
$counter = 1;
$originalUsername = $username;
while (HostingAccount::query()->where('username', $username)->exists()) {
$suffix = (string) $counter;
$maxBaseLength = 16 - strlen($suffix);
$username = substr($originalUsername, 0, $maxBaseLength).$suffix;
$counter++;
}
return $username;
}
private function expiresAtFromDuration(int $durationMonths): CarbonInterface
{
return now()->addMonthsNoOverflow($durationMonths);
}
}