VPS and dedicated server ordering, managed panels, SSO, billing, and launcher integration — forked from hosting infrastructure with server-focused routes and dashboard. Co-authored-by: Cursor <cursoragent@cursor.com>
943 lines
38 KiB
PHP
943 lines
38 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Hosting;
|
|
|
|
use App\Models\CustomerHostingOrder;
|
|
use App\Models\HostingProduct;
|
|
use App\Services\Hosting\Providers\ContaboProvider;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class ServerOrderService
|
|
{
|
|
private const INSTALL_LATER_IMAGE = '__install_later__';
|
|
private const PASSWORD_PATTERN = '/^((?=.*?[A-Z])(?=.*?[a-z]))(((?=(?:.*\d){1})(?=(?:.*[!@#$^&*?_~]){2,}))|((?=(?:.*\d){3})(?=.*?[!@#$^&*?_~]))).{8,}$/';
|
|
private const APPLICATION_NAME_MAPPINGS = [
|
|
'ipfs_node' => ['IPFS', 'ipfs'],
|
|
'flux_node' => ['Flux', 'flux', 'FluxOS'],
|
|
'horizon_node' => ['Horizen', 'horizon', 'Zen'],
|
|
'ethereum_node' => ['Ethereum', 'eth-docker'],
|
|
'bitcoin_node' => ['Bitcoin'],
|
|
];
|
|
private ?array $availableImages = null;
|
|
|
|
public function __construct(
|
|
private HostingPricingService $pricing,
|
|
private ContaboProvider $contaboProvider,
|
|
private ServerPanelCapabilityService $panelCapabilities,
|
|
) {}
|
|
|
|
public function passwordPattern(): string
|
|
{
|
|
return self::PASSWORD_PATTERN;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function frontendPayload(HostingProduct $product): array
|
|
{
|
|
$defaults = $this->defaultSelections($product);
|
|
|
|
return [
|
|
'id' => $product->id,
|
|
'name' => $product->name,
|
|
'currency' => $product->display_currency,
|
|
'prices' => $this->baseCyclePrices($product),
|
|
'setup_fees' => $this->setupFeesByCycle($product),
|
|
'catalog' => $this->catalogForProduct($product),
|
|
'defaults' => $defaults,
|
|
'panel_snapshot' => $this->panelCapabilities->describeSelection($product, $defaults, $this->catalogForProduct($product)),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function catalogForProduct(HostingProduct $product): array
|
|
{
|
|
$images = $this->imageOptions($product);
|
|
$defaultImage = $this->defaultImageOption($images)['value'] ?? null;
|
|
$defaultImageMeta = $defaultImage ? $this->findOption($images, $defaultImage) : null;
|
|
|
|
return [
|
|
'images' => $images,
|
|
'regions' => $this->optionCollection($product, (array) config('hosting.server_order.regions', [])),
|
|
'licenses' => $this->optionCollection($product, (array) config('hosting.server_order.licenses', [])),
|
|
'applications' => $this->applicationOptions($product),
|
|
'additional_ip' => $this->optionCollection($product, (array) config('hosting.server_order.additional_ip', [])),
|
|
'private_networking' => $this->optionCollection($product, (array) config('hosting.server_order.private_networking', [])),
|
|
'storage_types' => $this->optionCollection($product, (array) config('hosting.server_order.storage_types', [])),
|
|
'object_storage' => $this->optionCollection($product, (array) config('hosting.server_order.object_storage', [])),
|
|
'linux_default_users' => $this->defaultUserOptions($product, 'linux'),
|
|
'windows_default_users' => $this->defaultUserOptions($product, 'windows'),
|
|
'default_image_os_family' => $defaultImageMeta['os_family'] ?? 'linux',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $input
|
|
* @return array{selection: array<string, mixed>, quote: array<string, mixed>}
|
|
*/
|
|
public function selectionAndQuote(HostingProduct $product, array $input): array
|
|
{
|
|
$catalog = $this->catalogForProduct($product);
|
|
$defaults = $this->defaultSelections($product);
|
|
$billingCycle = (string) ($input['billing_cycle'] ?? CustomerHostingOrder::CYCLE_MONTHLY);
|
|
|
|
$selection = [
|
|
'image' => $this->validatedOptionValue($catalog['images'], (string) ($input['image'] ?? $defaults['image'])),
|
|
'region' => $this->validatedOptionValue($catalog['regions'], (string) ($input['region'] ?? $defaults['region'])),
|
|
'license' => $this->validatedOptionValue($catalog['licenses'], (string) ($input['license'] ?? $defaults['license'])),
|
|
'application' => $this->validatedOptionValue($catalog['applications'], (string) ($input['application'] ?? $defaults['application'])),
|
|
'additional_ip' => $this->validatedOptionValue($catalog['additional_ip'], (string) ($input['additional_ip'] ?? $defaults['additional_ip'])),
|
|
'private_networking' => $this->validatedOptionValue($catalog['private_networking'], (string) ($input['private_networking'] ?? $defaults['private_networking'])),
|
|
'storage_type' => $this->validatedOptionValue($catalog['storage_types'], (string) ($input['storage_type'] ?? $defaults['storage_type'])),
|
|
'object_storage' => $this->validatedOptionValue($catalog['object_storage'], (string) ($input['object_storage'] ?? $defaults['object_storage'])),
|
|
];
|
|
|
|
$image = $this->findOption($catalog['images'], $selection['image']);
|
|
$defaultUsers = $this->defaultUserOptions($product, (string) ($image['os_family'] ?? 'linux'));
|
|
$selection['default_user'] = $this->validatedOptionValue(
|
|
$defaultUsers,
|
|
(string) ($input['default_user'] ?? $image['default_user'] ?? array_key_first($defaultUsers))
|
|
);
|
|
$this->assertCompatibleSelections($selection, $catalog);
|
|
|
|
$quote = $this->quote($product, $selection, $billingCycle, $catalog);
|
|
|
|
return [
|
|
'selection' => $selection,
|
|
'quote' => $quote,
|
|
'selection_summary' => $this->selectionSummary($product, $selection, $catalog),
|
|
'panel_snapshot' => $this->panelCapabilities->describeSelection($product, $selection, $catalog),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $selection
|
|
* @param array<string, mixed>|null $catalog
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function quote(HostingProduct $product, array $selection, string $billingCycle, ?array $catalog = null): array
|
|
{
|
|
$catalog ??= $this->catalogForProduct($product);
|
|
$basePrices = $this->baseCyclePrices($product);
|
|
$baseTotal = (float) ($basePrices[$billingCycle] ?? 0);
|
|
|
|
$lines = [[
|
|
'code' => 'base_plan',
|
|
'label' => $product->name,
|
|
'amount' => round($baseTotal, 2),
|
|
'automated' => true,
|
|
]];
|
|
|
|
$manualFollowUp = [];
|
|
$setupFee = $this->setupFeeForCycle($product, $billingCycle);
|
|
|
|
if ($setupFee > 0) {
|
|
$lines[] = [
|
|
'code' => 'setup_fee',
|
|
'label' => $this->setupFeeLabel($product, $billingCycle),
|
|
'amount' => round($setupFee, 2),
|
|
'automated' => true,
|
|
'one_time' => true,
|
|
];
|
|
}
|
|
|
|
foreach ([
|
|
'image' => 'images',
|
|
'region' => 'regions',
|
|
'license' => 'licenses',
|
|
'application' => 'applications',
|
|
'additional_ip' => 'additional_ip',
|
|
'private_networking' => 'private_networking',
|
|
'storage_type' => 'storage_types',
|
|
'object_storage' => 'object_storage',
|
|
] as $selectionKey => $catalogKey) {
|
|
$value = (string) ($selection[$selectionKey] ?? '');
|
|
if ($value === '') {
|
|
continue;
|
|
}
|
|
|
|
$option = $this->findOption($catalog[$catalogKey], $value);
|
|
if (! $option) {
|
|
continue;
|
|
}
|
|
|
|
$amount = (float) ($option['prices'][$billingCycle] ?? 0);
|
|
if ($amount <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$lines[] = [
|
|
'code' => $selectionKey,
|
|
'label' => (string) ($option['label'] ?? $value),
|
|
'amount' => round($amount, 2),
|
|
'automated' => (bool) ($option['automated'] ?? false),
|
|
];
|
|
|
|
if (! ($option['automated'] ?? false)) {
|
|
$manualFollowUp[] = (string) ($option['label'] ?? $value);
|
|
}
|
|
}
|
|
|
|
$total = round(collect($lines)->sum('amount'), 2);
|
|
|
|
return [
|
|
'currency' => $product->display_currency,
|
|
'billing_cycle' => $billingCycle,
|
|
'months' => $this->cycleMonths($billingCycle),
|
|
'base_total' => round($baseTotal, 2),
|
|
'line_items' => $lines,
|
|
'total' => $total,
|
|
'manual_follow_up' => array_values(array_unique($manualFollowUp)),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $selection
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function provisioningConfig(HostingProduct $product, array $selection, string $serverName, string $billingCycle): array
|
|
{
|
|
$catalog = $this->catalogForProduct($product);
|
|
$image = $this->findOption($catalog['images'], (string) ($selection['image'] ?? ''));
|
|
$license = $this->findOption($catalog['licenses'], (string) ($selection['license'] ?? ''));
|
|
$application = $this->findOption($catalog['applications'], (string) ($selection['application'] ?? ''));
|
|
$region = $this->findOption($catalog['regions'], (string) ($selection['region'] ?? ''));
|
|
$additionalIp = $this->findOption($catalog['additional_ip'], (string) ($selection['additional_ip'] ?? ''));
|
|
$privateNetworking = $this->findOption($catalog['private_networking'], (string) ($selection['private_networking'] ?? ''));
|
|
$storageType = $this->findOption($catalog['storage_types'], (string) ($selection['storage_type'] ?? ''));
|
|
$objectStorage = $this->findOption($catalog['object_storage'], (string) ($selection['object_storage'] ?? ''));
|
|
|
|
$addOns = [];
|
|
foreach ([$image, $additionalIp, $privateNetworking, $storageType] as $option) {
|
|
$addOnKey = (string) ($option['add_on'] ?? '');
|
|
if ($addOnKey !== '') {
|
|
$addOns[$addOnKey] = new \stdClass();
|
|
}
|
|
}
|
|
|
|
$manualFollowUp = [];
|
|
foreach ([$license, $application, $storageType, $objectStorage] as $option) {
|
|
if ($option && ! ($option['automated'] ?? false)) {
|
|
$manualFollowUp[] = (string) ($option['label'] ?? $option['value'] ?? 'Option');
|
|
}
|
|
}
|
|
|
|
$applicationId = $application['application_id'] ?? null;
|
|
$userData = $this->cloudInitForOption((array) $application, (string) ($selection['default_user'] ?? ($image['default_user'] ?? 'root')));
|
|
|
|
return [
|
|
'product_id' => $product->contabo_product_id,
|
|
'image_id' => ! ($image['is_install_later'] ?? false) ? ($image['image_id'] ?? $selection['image'] ?? config('hosting.vps.default_image')) : null,
|
|
'region' => (string) ($region['value'] ?? $selection['region'] ?? $product->contabo_region ?? config('hosting.vps.default_region', 'EU')),
|
|
'display_name' => $serverName,
|
|
'default_user' => (string) ($selection['default_user'] ?? ($image['default_user'] ?? 'root')),
|
|
'license' => $license['license'] ?? null,
|
|
'panel' => $license['panel'] ?? null,
|
|
'application_id' => is_string($applicationId) && $applicationId !== '' ? $applicationId : null,
|
|
'user_data' => $userData,
|
|
'period' => $this->cycleMonths($billingCycle),
|
|
'add_ons' => $addOns,
|
|
'manual_follow_up' => array_values(array_unique($manualFollowUp)),
|
|
'panel_snapshot' => $this->panelCapabilities->describeSelection($product, $selection, $catalog),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function defaultSelections(HostingProduct $product): array
|
|
{
|
|
$catalog = $this->catalogForProduct($product);
|
|
$defaultImage = $catalog['images'][0]['value'] ?? null;
|
|
$defaultImageMeta = $defaultImage ? $this->findOption($catalog['images'], $defaultImage) : null;
|
|
$defaultUserOptions = $this->defaultUserOptions($product, (string) ($defaultImageMeta['os_family'] ?? 'linux'));
|
|
|
|
return [
|
|
'image' => $defaultImage,
|
|
'region' => (string) ($catalog['regions'][0]['value'] ?? 'EU'),
|
|
'license' => 'none',
|
|
'application' => 'none',
|
|
'additional_ip' => 'none',
|
|
'private_networking' => 'disabled',
|
|
'storage_type' => 'included',
|
|
'object_storage' => 'none',
|
|
'default_user' => (string) ($defaultUserOptions[0]['value'] ?? 'root'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $selection
|
|
* @param array<string, mixed>|null $catalog
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function selectionSummary(HostingProduct $product, array $selection, ?array $catalog = null): array
|
|
{
|
|
$catalog ??= $this->catalogForProduct($product);
|
|
$image = $this->findOption($catalog['images'], (string) ($selection['image'] ?? ''));
|
|
$defaultUsers = $this->defaultUserOptions($product, (string) ($image['os_family'] ?? 'linux'));
|
|
|
|
return array_values(array_filter([
|
|
$this->selectionSummaryItem('image', 'Image', $this->findOption($catalog['images'], (string) ($selection['image'] ?? ''))),
|
|
$this->selectionSummaryItem('region', 'Region', $this->findOption($catalog['regions'], (string) ($selection['region'] ?? ''))),
|
|
$this->selectionSummaryItem('license', 'License', $this->findOption($catalog['licenses'], (string) ($selection['license'] ?? ''))),
|
|
$this->selectionSummaryItem('application', 'Preinstalled App', $this->findOption($catalog['applications'], (string) ($selection['application'] ?? ''))),
|
|
$this->selectionSummaryItem('default_user', 'Default User', $this->findOption($defaultUsers, (string) ($selection['default_user'] ?? '')), true),
|
|
$this->selectionSummaryItem('additional_ip', 'IP Address', $this->findOption($catalog['additional_ip'], (string) ($selection['additional_ip'] ?? ''))),
|
|
$this->selectionSummaryItem('private_networking', 'Private Networking', $this->findOption($catalog['private_networking'], (string) ($selection['private_networking'] ?? ''))),
|
|
$this->selectionSummaryItem('storage_type', 'Storage Type', $this->findOption($catalog['storage_types'], (string) ($selection['storage_type'] ?? ''))),
|
|
$this->selectionSummaryItem('object_storage', 'Object Storage', $this->findOption($catalog['object_storage'], (string) ($selection['object_storage'] ?? ''))),
|
|
]));
|
|
}
|
|
|
|
/**
|
|
* @return array<string, float>
|
|
*/
|
|
public function baseCyclePrices(HostingProduct $product): array
|
|
{
|
|
$cycles = [
|
|
CustomerHostingOrder::CYCLE_MONTHLY,
|
|
CustomerHostingOrder::CYCLE_QUARTERLY,
|
|
CustomerHostingOrder::CYCLE_SEMIANNUAL,
|
|
CustomerHostingOrder::CYCLE_YEARLY,
|
|
];
|
|
|
|
$prices = [];
|
|
foreach ($cycles as $cycle) {
|
|
$price = $product->getPriceForCycle($cycle);
|
|
if ($price !== null) {
|
|
$prices[$cycle] = (float) $price;
|
|
}
|
|
}
|
|
|
|
return $prices;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, float>
|
|
*/
|
|
public function setupFeesByCycle(HostingProduct $product): array
|
|
{
|
|
$fees = [];
|
|
foreach (array_keys($this->baseCyclePrices($product)) as $cycle) {
|
|
$fees[$cycle] = $this->setupFeeForCycle($product, $cycle);
|
|
}
|
|
|
|
return $fees;
|
|
}
|
|
|
|
public function setupFeeForCycle(HostingProduct $product, string $billingCycle): float
|
|
{
|
|
if (! $product->requiresContabo()) {
|
|
return 0.0;
|
|
}
|
|
|
|
$productId = trim((string) $product->contabo_product_id);
|
|
|
|
if ($productId === '') {
|
|
return 0.0;
|
|
}
|
|
|
|
foreach ((array) config('hosting.pricing.setup_fee_rules', []) as $rule) {
|
|
if (! in_array($productId, array_map('strval', (array) ($rule['product_ids'] ?? [])), true)) {
|
|
continue;
|
|
}
|
|
|
|
if (! in_array($billingCycle, array_map('strval', (array) ($rule['billing_cycles'] ?? [])), true)) {
|
|
continue;
|
|
}
|
|
|
|
if (isset($rule['fixed_eur_by_cycle'])) {
|
|
$eurAmount = (float) ($rule['fixed_eur_by_cycle'][$billingCycle] ?? 0);
|
|
if ($eurAmount <= 0) {
|
|
return 0.0;
|
|
}
|
|
$eurToGhs = app(\App\Services\ExchangeRateService::class)->getEurToGhs();
|
|
$margin = $this->pricing->getMargin($product->category);
|
|
$ghs = $eurAmount * $eurToGhs * (1 + ($margin / 100));
|
|
return round($ghs / 5) * 5;
|
|
}
|
|
|
|
$monthlyPrice = (float) $product->getPriceForCycle(CustomerHostingOrder::CYCLE_MONTHLY);
|
|
$multiplier = (float) ($rule['monthly_price_multiplier'] ?? 1);
|
|
|
|
return round($monthlyPrice * $multiplier, 2);
|
|
}
|
|
|
|
return 0.0;
|
|
}
|
|
|
|
private function setupFeeLabel(HostingProduct $product, string $billingCycle): string
|
|
{
|
|
$productId = trim((string) $product->contabo_product_id);
|
|
|
|
foreach ((array) config('hosting.pricing.setup_fee_rules', []) as $rule) {
|
|
if (
|
|
in_array($productId, array_map('strval', (array) ($rule['product_ids'] ?? [])), true)
|
|
&& in_array($billingCycle, array_map('strval', (array) ($rule['billing_cycles'] ?? [])), true)
|
|
) {
|
|
return (string) ($rule['label'] ?? 'One-time setup fee');
|
|
}
|
|
}
|
|
|
|
return 'One-time setup fee';
|
|
}
|
|
|
|
public function cycleMonths(string $billingCycle): int
|
|
{
|
|
return match ($billingCycle) {
|
|
CustomerHostingOrder::CYCLE_MONTHLY => 1,
|
|
CustomerHostingOrder::CYCLE_QUARTERLY => 3,
|
|
CustomerHostingOrder::CYCLE_SEMIANNUAL => 6,
|
|
CustomerHostingOrder::CYCLE_YEARLY => 12,
|
|
CustomerHostingOrder::CYCLE_BIENNIAL => 24,
|
|
default => 1,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $options
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function findOption(array $options, ?string $value): ?array
|
|
{
|
|
foreach ($options as $option) {
|
|
if ((string) ($option['value'] ?? '') === (string) $value) {
|
|
return $option;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $options
|
|
*/
|
|
private function validatedOptionValue(array $options, string $value): string
|
|
{
|
|
return $this->findOption($options, $value)['value'] ?? (string) ($options[0]['value'] ?? '');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, array<string, mixed>> $options
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function optionCollection(HostingProduct $product, array $options): array
|
|
{
|
|
$items = [];
|
|
|
|
foreach ($options as $value => $option) {
|
|
if (! $this->optionIsAvailable($option, (string) $value)) {
|
|
continue;
|
|
}
|
|
|
|
$items[] = [
|
|
'value' => (string) $value,
|
|
'label' => (string) ($option['label'] ?? $value),
|
|
'description' => (string) ($option['description'] ?? ''),
|
|
'monthly_usd' => (float) ($option['monthly_usd'] ?? 0),
|
|
'prices' => $this->cyclePricesForRecurringUsd($product, (float) ($option['monthly_usd'] ?? 0)),
|
|
'license' => $option['license'] ?? null,
|
|
'panel' => $option['panel'] ?? null,
|
|
'application_id' => $option['application_id'] ?? null,
|
|
'cloud_init_preset' => $option['cloud_init_preset'] ?? null,
|
|
'add_on' => $option['add_on'] ?? null,
|
|
'compatible_os_families' => $this->normalizedOsFamilies($option['compatible_os_families'] ?? []),
|
|
'requires_image' => (bool) ($option['requires_image'] ?? false),
|
|
'requires_managed_stack_image' => (bool) ($option['requires_managed_stack_image'] ?? false),
|
|
'automated' => (bool) ($option['automated'] ?? false),
|
|
];
|
|
}
|
|
|
|
return $items;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function imageOptions(HostingProduct $product): array
|
|
{
|
|
if ($this->availableImages !== null) {
|
|
return $this->availableImages;
|
|
}
|
|
|
|
$images = [];
|
|
|
|
try {
|
|
foreach ($this->contaboProvider->getAvailableImages() as $image) {
|
|
$rule = $this->matchImageRule($image);
|
|
|
|
$images[] = [
|
|
'value' => (string) $image['id'],
|
|
'label' => (string) ($image['name'] ?? ($rule['label'] ?? 'Image')),
|
|
'description' => (string) ($image['description'] ?? ($rule['label'] ?? '')),
|
|
'image_id' => (string) $image['id'],
|
|
'os_family' => (string) ($rule['os_family'] ?? 'linux'),
|
|
'default_user' => (string) ($rule['default_user'] ?? 'root'),
|
|
'monthly_usd' => (float) ($rule['monthly_usd'] ?? 0),
|
|
'prices' => $this->cyclePricesForRecurringUsd($product, (float) ($rule['monthly_usd'] ?? 0)),
|
|
'automated' => true,
|
|
'add_on' => ! empty($rule['requires_custom_image_addon']) ? 'customImage' : null,
|
|
];
|
|
}
|
|
} catch (\Throwable) {
|
|
// Fall back to configured image list when the API is unavailable.
|
|
}
|
|
|
|
if ($images !== []) {
|
|
usort($images, fn (array $a, array $b): int => strcmp((string) ($a['label'] ?? ''), (string) ($b['label'] ?? '')));
|
|
|
|
return $this->availableImages = $this->prependInstallLaterImage($product, $images);
|
|
}
|
|
|
|
$fallback = [];
|
|
foreach ((array) config('hosting.server_order.fallback_images', []) as $image) {
|
|
$fallback[] = [
|
|
'value' => (string) ($image['value'] ?? ''),
|
|
'label' => (string) ($image['label'] ?? 'Image'),
|
|
'description' => (string) ($image['description'] ?? ''),
|
|
'image_id' => (string) ($image['value'] ?? ''),
|
|
'os_family' => (string) ($image['os_family'] ?? 'linux'),
|
|
'default_user' => (string) ($image['default_user'] ?? 'root'),
|
|
'monthly_usd' => (float) ($image['monthly_usd'] ?? 0),
|
|
'prices' => $this->cyclePricesForRecurringUsd($product, (float) ($image['monthly_usd'] ?? 0)),
|
|
'automated' => (bool) ($image['automated'] ?? false),
|
|
'add_on' => ! empty($image['requires_custom_image_addon']) ? 'customImage' : null,
|
|
];
|
|
}
|
|
|
|
return $this->availableImages = $this->prependInstallLaterImage($product, $fallback);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $image
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function matchImageRule(array $image): array
|
|
{
|
|
$name = strtolower(trim((string) ($image['name'] ?? '')));
|
|
$standardImage = (bool) ($image['standard_image'] ?? true);
|
|
$rules = (array) config('hosting.server_order.image_pricing_rules', []);
|
|
|
|
foreach ($rules as $rule) {
|
|
$isCustomRule = (bool) ($rule['custom_image'] ?? false);
|
|
if ($isCustomRule && ! $standardImage) {
|
|
return $rule;
|
|
}
|
|
|
|
foreach ((array) ($rule['match'] ?? []) as $needle) {
|
|
if ($needle !== '' && str_contains($name, strtolower((string) $needle))) {
|
|
return $rule;
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'label' => $image['name'] ?? 'Image',
|
|
'monthly_usd' => 0,
|
|
'os_family' => str_contains(strtolower((string) ($image['os_type'] ?? 'linux')), 'win') ? 'windows' : 'linux',
|
|
'default_user' => str_contains(strtolower((string) ($image['os_type'] ?? 'linux')), 'win') ? 'administrator' : 'root',
|
|
'requires_custom_image_addon' => ! $standardImage,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function defaultUserOptions(HostingProduct $product, string $osFamily): array
|
|
{
|
|
$configured = str_starts_with($osFamily, 'win')
|
|
? (array) config('hosting.server_order.windows_default_users', [])
|
|
: (array) config('hosting.server_order.linux_default_users', []);
|
|
|
|
$options = [];
|
|
|
|
foreach ($configured as $value => $option) {
|
|
$options[] = [
|
|
'value' => (string) $value,
|
|
'label' => (string) ($option['label'] ?? $value),
|
|
];
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function applicationOptions(HostingProduct $product): array
|
|
{
|
|
$configApps = (array) config('hosting.server_order.applications', []);
|
|
$cachedIds = \Illuminate\Support\Facades\Cache::get('contabo_blockchain_app_ids', []);
|
|
|
|
if ($this->hasMissingConfiguredApplicationIds($configApps)) {
|
|
$resolvedIds = $this->resolveApplicationIdsFromCatalog($configApps);
|
|
if ($resolvedIds !== []) {
|
|
$cachedIds = array_merge($cachedIds, $resolvedIds);
|
|
\Illuminate\Support\Facades\Cache::put('contabo_blockchain_app_ids', $cachedIds, now()->addDays(7));
|
|
}
|
|
}
|
|
|
|
foreach ($configApps as $key => $option) {
|
|
$appId = $option['application_id'] ?? null;
|
|
if (($appId === null || $appId === '') && isset($cachedIds[$key])) {
|
|
$configApps[$key]['application_id'] = $cachedIds[$key]['id'];
|
|
$appId = $configApps[$key]['application_id'];
|
|
}
|
|
|
|
$preset = trim((string) ($option['cloud_init_preset'] ?? ''));
|
|
$resolvedAppId = trim((string) $appId);
|
|
|
|
if ($key !== 'none' && $resolvedAppId === '' && $preset === '') {
|
|
$configApps[$key]['allow_unresolved'] = true;
|
|
$configApps[$key]['automated'] = false;
|
|
|
|
$description = trim((string) ($configApps[$key]['description'] ?? ''));
|
|
if ($description !== '' && ! str_contains(strtolower($description), 'manual')) {
|
|
$configApps[$key]['description'] = $description.' Manual setup may be required after provisioning.';
|
|
}
|
|
}
|
|
}
|
|
|
|
return $this->optionCollection($product, $configApps);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, array<string, mixed>> $configApps
|
|
*/
|
|
private function hasMissingConfiguredApplicationIds(array $configApps): bool
|
|
{
|
|
foreach ($configApps as $key => $option) {
|
|
if (! array_key_exists($key, self::APPLICATION_NAME_MAPPINGS)) {
|
|
continue;
|
|
}
|
|
|
|
if (trim((string) ($option['application_id'] ?? '')) === '') {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, array<string, mixed>> $configApps
|
|
* @return array<string, array{id: string, name: string, description: string}>
|
|
*/
|
|
private function resolveApplicationIdsFromCatalog(array $configApps): array
|
|
{
|
|
try {
|
|
$applications = $this->contaboProvider->getAvailableApplications();
|
|
} catch (\Throwable) {
|
|
return [];
|
|
}
|
|
|
|
$mapped = [];
|
|
|
|
foreach (self::APPLICATION_NAME_MAPPINGS as $configKey => $searchTerms) {
|
|
if (! isset($configApps[$configKey])) {
|
|
continue;
|
|
}
|
|
|
|
if (trim((string) ($configApps[$configKey]['application_id'] ?? '')) !== '') {
|
|
continue;
|
|
}
|
|
|
|
foreach ($applications as $app) {
|
|
$name = strtolower((string) ($app['name'] ?? ''));
|
|
foreach ($searchTerms as $term) {
|
|
if ($term !== '' && str_contains($name, strtolower($term))) {
|
|
$mapped[$configKey] = [
|
|
'id' => (string) ($app['id'] ?? ''),
|
|
'name' => (string) ($app['name'] ?? $configKey),
|
|
'description' => (string) ($app['description'] ?? ''),
|
|
];
|
|
break 2;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_filter($mapped, static fn (array $app): bool => $app['id'] !== '');
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $images
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function defaultImageOption(array $images): ?array
|
|
{
|
|
foreach ($images as $image) {
|
|
if (! ($image['is_install_later'] ?? false)) {
|
|
return $image;
|
|
}
|
|
}
|
|
|
|
return $images[0] ?? null;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $images
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function prependInstallLaterImage(HostingProduct $product, array $images): array
|
|
{
|
|
array_unshift($images, [
|
|
'value' => self::INSTALL_LATER_IMAGE,
|
|
'label' => 'Install Later',
|
|
'description' => 'Provision the server first and choose the final OS installation afterward.',
|
|
'image_id' => null,
|
|
'os_family' => 'linux',
|
|
'default_user' => 'root',
|
|
'monthly_usd' => 0.0,
|
|
'prices' => $this->cyclePricesForRecurringUsd($product, 0.0),
|
|
'automated' => true,
|
|
'add_on' => null,
|
|
'is_install_later' => true,
|
|
]);
|
|
|
|
return $images;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $selection
|
|
* @param array<string, mixed> $catalog
|
|
*/
|
|
private function assertCompatibleSelections(array $selection, array $catalog): void
|
|
{
|
|
$image = $this->findOption($catalog['images'], (string) ($selection['image'] ?? ''));
|
|
$license = $this->findOption($catalog['licenses'], (string) ($selection['license'] ?? ''));
|
|
$application = $this->findOption($catalog['applications'], (string) ($selection['application'] ?? ''));
|
|
|
|
$errors = [];
|
|
|
|
if (! $this->optionSupportsImage($license, $image)) {
|
|
$errors['license'] = 'The selected control panel is not compatible with this image.';
|
|
}
|
|
|
|
if (! $this->optionSupportsImage($application, $image)) {
|
|
$errors['application'] = 'The selected preinstalled app is not compatible with this image.';
|
|
}
|
|
|
|
if ($errors !== []) {
|
|
throw ValidationException::withMessages($errors);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $option
|
|
* @param array<string, mixed>|null $image
|
|
*/
|
|
private function optionSupportsImage(?array $option, ?array $image): bool
|
|
{
|
|
if (! $option) {
|
|
return true;
|
|
}
|
|
|
|
$imageLabel = strtolower((string) ($image['label'] ?? ''));
|
|
$bundledPanel = $this->bundledPanelFromImageLabel($imageLabel);
|
|
$optionLicense = strtolower((string) ($option['license'] ?? ''));
|
|
|
|
if (($option['requires_image'] ?? false) && ($image['is_install_later'] ?? false)) {
|
|
return false;
|
|
}
|
|
|
|
if (($option['requires_managed_stack_image'] ?? false) && ! $this->imageSupportsManagedStack($image)) {
|
|
return false;
|
|
}
|
|
|
|
if ($bundledPanel !== null && (string) ($option['value'] ?? '') === 'none') {
|
|
return false;
|
|
}
|
|
|
|
if ($bundledPanel === 'cpanel' && str_starts_with($optionLicense, 'plesk')) {
|
|
return false;
|
|
}
|
|
|
|
if ($bundledPanel === 'plesk' && str_starts_with($optionLicense, 'cpanel')) {
|
|
return false;
|
|
}
|
|
|
|
$families = (array) ($option['compatible_os_families'] ?? []);
|
|
if ($families === []) {
|
|
return true;
|
|
}
|
|
|
|
$osFamily = strtolower((string) ($image['os_family'] ?? 'linux'));
|
|
foreach ($families as $family) {
|
|
if ($family !== '' && str_starts_with($osFamily, strtolower((string) $family))) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $image
|
|
*/
|
|
private function imageSupportsManagedStack(?array $image): bool
|
|
{
|
|
if (! $image || ($image['is_install_later'] ?? false)) {
|
|
return false;
|
|
}
|
|
|
|
$imageLabel = strtolower((string) ($image['label'] ?? ''));
|
|
$osFamily = strtolower((string) ($image['os_family'] ?? 'linux'));
|
|
|
|
if (str_starts_with($osFamily, 'win') || $this->imageIncludesVendorPanel($imageLabel)) {
|
|
return false;
|
|
}
|
|
|
|
foreach ((array) config('hosting.server_order.managed_stack_supported_images', []) as $supported) {
|
|
$supportedOsFamily = strtolower((string) ($supported['os_family'] ?? 'linux'));
|
|
if ($supportedOsFamily !== '' && $supportedOsFamily !== $osFamily) {
|
|
continue;
|
|
}
|
|
|
|
foreach ((array) ($supported['match'] ?? []) as $needle) {
|
|
$needle = strtolower(trim((string) $needle));
|
|
if ($needle !== '' && str_contains($imageLabel, $needle)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function imageIncludesVendorPanel(string $imageLabel): bool
|
|
{
|
|
return $this->bundledPanelFromImageLabel($imageLabel) !== null;
|
|
}
|
|
|
|
private function bundledPanelFromImageLabel(string $imageLabel): ?string
|
|
{
|
|
if (str_contains($imageLabel, 'cpanel') || str_contains($imageLabel, 'whm')) {
|
|
return 'cpanel';
|
|
}
|
|
|
|
if (str_contains($imageLabel, 'plesk')) {
|
|
return 'plesk';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $option
|
|
*/
|
|
private function optionIsAvailable(array $option, string $value): bool
|
|
{
|
|
if ($value === 'none') {
|
|
return true;
|
|
}
|
|
|
|
if (! empty($option['allow_unresolved'])) {
|
|
return true;
|
|
}
|
|
|
|
$applicationId = trim((string) ($option['application_id'] ?? ''));
|
|
$preset = trim((string) ($option['cloud_init_preset'] ?? ''));
|
|
|
|
if ($applicationId !== '' || $preset !== '') {
|
|
return true;
|
|
}
|
|
|
|
return array_key_exists('application_id', $option) || array_key_exists('cloud_init_preset', $option)
|
|
? false
|
|
: true;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $families
|
|
* @return array<int, string>
|
|
*/
|
|
private function normalizedOsFamilies(mixed $families): array
|
|
{
|
|
$items = is_array($families) ? $families : [];
|
|
|
|
return array_values(array_filter(array_map(
|
|
static fn ($family): string => strtolower(trim((string) $family)),
|
|
$items
|
|
)));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $option
|
|
*/
|
|
private function cloudInitForOption(array $option, string $defaultUser): ?string
|
|
{
|
|
$preset = (string) ($option['cloud_init_preset'] ?? '');
|
|
if ($preset === '') {
|
|
return null;
|
|
}
|
|
|
|
return match ($preset) {
|
|
'webmin' => $this->contaboProvider->generateCloudInit([
|
|
'packages' => ['curl', 'wget'],
|
|
'run_commands' => [
|
|
'bash -lc \'if command -v apt-get >/dev/null 2>&1; then curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y webmin; elif command -v dnf >/dev/null 2>&1; then dnf install -y perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && dnf install -y webmin; elif command -v yum >/dev/null 2>&1; then yum install -y perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && yum install -y webmin; fi\'',
|
|
],
|
|
]),
|
|
'webmin_lamp' => $this->contaboProvider->generateCloudInit([
|
|
'packages' => ['curl', 'wget'],
|
|
'run_commands' => [
|
|
'bash -lc \'if command -v apt-get >/dev/null 2>&1; then apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y apache2 mariadb-server php libapache2-mod-php php-mysql curl wget && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y webmin; elif command -v dnf >/dev/null 2>&1; then dnf install -y httpd mariadb-server php php-mysqlnd curl wget perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && dnf install -y webmin; elif command -v yum >/dev/null 2>&1; then yum install -y httpd mariadb-server php php-mysqlnd curl wget perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && yum install -y webmin; fi && (systemctl enable --now apache2 || systemctl enable --now httpd || true) && (systemctl enable --now mariadb || systemctl enable --now mysqld || true)\'',
|
|
],
|
|
]),
|
|
default => $this->contaboProvider->generateCloudInit([
|
|
'packages' => ['curl', 'wget'],
|
|
'run_commands' => [
|
|
sprintf('echo "Preset %s selected for %s"', $preset, $defaultUser),
|
|
],
|
|
]),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $option
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function selectionSummaryItem(string $key, string $label, ?array $option, bool $priceOptional = false): ?array
|
|
{
|
|
if (! $option) {
|
|
return null;
|
|
}
|
|
|
|
$amount = (float) ($option['prices'][CustomerHostingOrder::CYCLE_MONTHLY] ?? 0);
|
|
|
|
if (! $priceOptional && ($option['label'] ?? null) === null) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'key' => $key,
|
|
'label' => $label,
|
|
'value' => (string) ($option['label'] ?? $option['value'] ?? ''),
|
|
'description' => (string) ($option['description'] ?? ''),
|
|
'automated' => (bool) ($option['automated'] ?? true),
|
|
'monthly_amount' => round($amount, 2),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, float>
|
|
*/
|
|
private function cyclePricesForRecurringUsd(HostingProduct $product, float $monthlyUsd): array
|
|
{
|
|
$monthlyGhs = $monthlyUsd > 0
|
|
? $this->pricing->convertUsdRecurringOption($monthlyUsd, $product->category)
|
|
: 0.0;
|
|
|
|
return [
|
|
CustomerHostingOrder::CYCLE_MONTHLY => round($monthlyGhs, 2),
|
|
CustomerHostingOrder::CYCLE_QUARTERLY => round($monthlyGhs * 3, 2),
|
|
CustomerHostingOrder::CYCLE_SEMIANNUAL => round($monthlyGhs * 6, 2),
|
|
CustomerHostingOrder::CYCLE_YEARLY => round($monthlyGhs * 12, 2),
|
|
];
|
|
}
|
|
}
|