diff --git a/app/Actions/Application/CleanupCancelledDeployment.php b/app/Actions/Application/CleanupCancelledDeployment.php index 171e660e66..5da94db3cb 100644 --- a/app/Actions/Application/CleanupCancelledDeployment.php +++ b/app/Actions/Application/CleanupCancelledDeployment.php @@ -28,6 +28,12 @@ class CleanupCancelledDeployment if (str($containerExists)->trim()->isNotEmpty()) { instant_remote_process(["docker rm -f {$deploymentUuid}"], $server); $containerStopped = true; + + // A removed helper cannot stop the Railpack builder, which keeps the memory of its builds. + if ($deployment->application?->build_pack === 'railpack') { + $helperImage = coolifyHelperImage().':'.getHelperVersion(); + instant_remote_process([railpackBuilderHelperCommand(railpackBuildxMetadataVolume($server), $helperImage, 'true')], $server, false); + } } } catch (\Throwable $e) { $firstError ??= $e; diff --git a/app/Actions/Database/FlushDatabase.php b/app/Actions/Database/FlushDatabase.php new file mode 100644 index 0000000000..d6fd152490 --- /dev/null +++ b/app/Actions/Database/FlushDatabase.php @@ -0,0 +1,31 @@ +enable_ssl) { + $cli .= ' --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key'; + } + + $output = instant_remote_process( + ["docker exec {$database->uuid} sh -c 'REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'"], + $database->destination->server, + ); + + if ($output !== 'OK') { + throw new RuntimeException($output ?: 'Failed to flush the database.'); + } + } +} diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php index 169064e180..98c0e0bc64 100644 --- a/app/Actions/Proxy/StartProxy.php +++ b/app/Actions/Proxy/StartProxy.php @@ -56,10 +56,11 @@ class StartProxy "echo 'Successfully started coolify-proxy.'", ]); } else { - $caddyfile = 'import /dynamic/*.caddy'; + // The base Caddyfile also holds the maintenance blocks (see Server::caddyBaseCaddyfile()). + $caddyfile = base64_encode($server->caddyBaseCaddyfile()); $commands = $commands->merge([ "mkdir -p $proxy_path/dynamic", - "echo '$caddyfile' | tee $proxy_path/dynamic/Caddyfile > /dev/null", + "echo '$caddyfile' | base64 -d | tee $proxy_path/dynamic/Caddyfile > /dev/null", "echo 'Creating required Docker Compose file.'", "echo 'Pulling docker image.'", "docker compose -f $compose_file pull", diff --git a/app/Actions/Proxy/StopProxy.php b/app/Actions/Proxy/StopProxy.php index d3999a6e30..881aa0252f 100644 --- a/app/Actions/Proxy/StopProxy.php +++ b/app/Actions/Proxy/StopProxy.php @@ -23,11 +23,16 @@ class StopProxy ProxyStatusChangedUI::dispatch($server->team_id); } + $errorPageCommands = $server->isSwarm() ? [] : [ + 'docker rm -f '.Server::PROXY_ERROR_PAGE_CONTAINER.' 2>/dev/null || true', + ]; + instant_remote_process(command: [ dockerStopCommand($timeout, $containerName, $server).' 2>/dev/null || true', "docker rm -f $containerName 2>/dev/null || true", // The Traefik traffic analytics log rotation sidecar must not outlive the proxy. 'docker rm -f '.TRAEFIK_LOGROTATE_CONTAINER.' 2>/dev/null || true', + ...$errorPageCommands, '# Wait for container to be fully removed', 'for i in {1..10}; do', " if ! docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then", diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 201f86b367..3c0e2ba7e0 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -135,9 +135,6 @@ class CleanupDocker implements ShouldBeUnique $helperImageWithVersion = "$helperImage:$helperImageVersion"; $helperImageWithoutPrefix = 'coollabsio/coolify-helper'; $helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion"; - $buildxMetadataVolume = isDev() && $server->isLocalhost() - ? 'coolify-buildx' - : '$HOME/.docker/buildx'; $cleanupLog = []; @@ -164,7 +161,8 @@ class CleanupDocker implements ShouldBeUnique 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"', $imagePruneCmd, 'docker builder prune -af', - "docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true", + // The prune fails on a stopped builder, so start it first. + railpackBuilderHelperCommand(railpackBuildxMetadataVolume($server), $helperImageWithVersion, 'docker buildx inspect --bootstrap coolify-railpack >/dev/null 2>&1; docker buildx prune --builder coolify-railpack -af'), "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f", ]; diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index c6f0320133..428d696fdc 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -8,6 +8,7 @@ use App\Models\Team; use App\Notifications\Server\HetznerDeletionFailed; use App\Services\DigitalOceanService; use App\Services\HetznerService; +use App\Services\HostingerService; use App\Services\VultrService; use Lorisleiva\Actions\Concerns\AsAction; @@ -15,7 +16,7 @@ class DeleteServer { use AsAction; - public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null, bool $deleteFromDigitalOcean = false, ?int $digitalOceanDropletId = null) + public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null, bool $deleteFromDigitalOcean = false, ?int $digitalOceanDropletId = null, bool $deleteFromHostinger = false, ?int $hostingerVirtualMachineId = null) { $server = Server::withTrashed()->find($serverId); @@ -44,6 +45,14 @@ class DeleteServer ); } + if ($deleteFromHostinger && ($hostingerVirtualMachineId || ($server && $server->hostinger_virtual_machine_id))) { + $this->deleteFromHostingerById( + $hostingerVirtualMachineId ?? $server->hostinger_virtual_machine_id, + $cloudProviderTokenId ?? $server->cloud_provider_token_id, + $teamId ?? $server->team_id + ); + } + logger()->debug($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion'); // If server is already deleted from Coolify, skip this part @@ -182,4 +191,50 @@ class DeleteServer throw $e; } } + + private function deleteFromHostingerById(int $hostingerVirtualMachineId, ?int $cloudProviderTokenId, int $teamId): void + { + try { + $token = null; + + if ($cloudProviderTokenId) { + $token = CloudProviderToken::where('id', $cloudProviderTokenId) + ->where('team_id', $teamId) + ->where('provider', 'hostinger') + ->first(); + } + + if (! $token) { + $token = CloudProviderToken::where('team_id', $teamId) + ->where('provider', 'hostinger') + ->first(); + } + + if (! $token) { + throw new \RuntimeException('No Hostinger token found for the server team.'); + } + + $hostingerService = new HostingerService($token->token); + $subscriptionId = $hostingerService->getVirtualMachine($hostingerVirtualMachineId)['subscription_id'] ?? null; + + if (! $subscriptionId) { + throw new \RuntimeException('The Hostinger VPS has no subscription.'); + } + + $hostingerService->disableAutoRenewal($subscriptionId); + + logger()->debug('Disabled Hostinger auto-renewal, so Hostinger deletes the VPS at the end of the paid period', [ + 'hostinger_virtual_machine_id' => $hostingerVirtualMachineId, + 'team_id' => $teamId, + ]); + } catch (\Throwable $e) { + logger()->error('Failed to delete VPS from Hostinger', [ + 'error' => $e->getMessage(), + 'hostinger_virtual_machine_id' => $hostingerVirtualMachineId, + 'team_id' => $teamId, + ]); + + throw $e; + } + } } diff --git a/app/Actions/Server/ValidateServer.php b/app/Actions/Server/ValidateServer.php index 9bb15c2053..7c973130c4 100644 --- a/app/Actions/Server/ValidateServer.php +++ b/app/Actions/Server/ValidateServer.php @@ -63,6 +63,15 @@ class ValidateServer } } + if ($server->hostinger_virtual_machine_id) { + $status = $server->refreshHostingerState(); + if (in_array($status, ['stopping', 'stopped', 'suspending', 'suspended', 'destroying', 'destroyed', 'error'], true)) { + $this->error = 'Hostinger VPS is '.($status ?? 'not running').'. Power it on before validating.'; + $server->update(['validation_logs' => $this->error]); + throw new \Exception($this->error); + } + } + ['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection(); if (! $this->uptime) { $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index eeb394e795..a5812ced35 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -8,6 +8,7 @@ use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Decorators\JobDecorator; @@ -66,6 +67,14 @@ class StartService } } $commands = array_merge($commands, $this->logDrainNetworkConnectCommands($service)); + if ($service->is_maintenance_enabled) { + // Domains can change with the compose file, so the maintenance routes follow them. + try { + $service->syncMaintenancePage(); + } catch (\Throwable $e) { + Log::warning('Maintenance page update failed while starting service.', ['service_uuid' => $service->uuid, 'error' => $e->getMessage()]); + } + } return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStartFinished', callEventData: $service->id, queue: deployment_queue()); } diff --git a/app/Console/Commands/Generate/OpenApi.php b/app/Console/Commands/Generate/OpenApi.php index d1e018b0c4..64d83e1bf9 100644 --- a/app/Console/Commands/Generate/OpenApi.php +++ b/app/Console/Commands/Generate/OpenApi.php @@ -17,7 +17,8 @@ class OpenApi extends Command // Generate OpenAPI documentation echo "Generating OpenAPI documentation.\n"; // https://github.com/OAI/OpenAPI-Specification/releases - $process = Process::run([ + // swagger-php 6 leaves PHP warnings to PHP itself; with Xdebug each one carries a stack trace and exhausts memory. + $process = Process::env(['XDEBUG_MODE' => 'off'])->run([ './vendor/bin/openapi', 'app', // Server transfer endpoints are development-only (isDev()), so they stay out of the public docs. @@ -28,11 +29,11 @@ class OpenApi extends Command '--version', '3.1.0', ]); - $error = $process->errorOutput(); - $error = preg_replace('/^.*an object literal,.*$/m', '', $error); - $error = preg_replace('/^\h*\v+/m', '', $error); - echo $error; - echo $process->output(); + foreach ([$process->errorOutput(), $process->output()] as $output) { + $output = preg_replace('/^.*an object literal,.*$/m', '', $output); + $output = preg_replace('/^\h*\v+/m', '', $output); + echo $output; + } $yaml = file_get_contents('openapi.yaml'); diff --git a/app/Enums/HttpBasicAuthHashAlgorithm.php b/app/Enums/HttpBasicAuthHashAlgorithm.php new file mode 100644 index 0000000000..c6340c0a1f --- /dev/null +++ b/app/Enums/HttpBasicAuthHashAlgorithm.php @@ -0,0 +1,9 @@ + ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'], @@ -640,6 +645,10 @@ class ApplicationsController extends Controller 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'], @@ -835,6 +844,10 @@ class ApplicationsController extends Controller 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'], @@ -1001,6 +1014,10 @@ class ApplicationsController extends Controller 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'], @@ -1163,6 +1180,10 @@ class ApplicationsController extends Controller 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'], @@ -1248,7 +1269,7 @@ class ApplicationsController extends Controller if ($return instanceof JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -1260,7 +1281,9 @@ class ApplicationsController extends Controller 'destination_uuid' => 'string', 'is_http_basic_auth_enabled' => 'boolean', 'http_basic_auth_username' => 'string|nullable', - 'http_basic_auth_password' => 'string|nullable', + // bcrypt ignores everything after 72 bytes. Labels for Traefik always use bcrypt and are also generated on + // Caddy servers when "Labels for all supported proxies" is selected, so that the proxy can be switched easily. + 'http_basic_auth_password' => 'string|nullable|max:72', 'autogenerate_domain' => 'boolean', 'tags' => 'array|nullable', 'tags.*' => 'string|min:2', @@ -3130,6 +3153,10 @@ class ApplicationsController extends Controller 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], 'use_build_secrets' => ['type' => 'boolean', 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], @@ -3250,7 +3277,7 @@ class ApplicationsController extends Controller $dnsCleanup = app(ManagedDnsRecordCleanup::class); $previousDnsHostnames = $dnsCleanup->hostnamesOf($application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; + $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; $validationRules = [ 'name' => 'string|max:255', @@ -3265,7 +3292,9 @@ class ApplicationsController extends Controller 'is_http_basic_auth_enabled' => 'boolean|nullable', 'is_preview_deployments_enabled' => 'boolean|nullable', 'http_basic_auth_username' => 'string', - 'http_basic_auth_password' => 'string', + // bcrypt ignores everything after 72 bytes. Labels for Traefik always use bcrypt and are also generated on + // Caddy servers when "Labels for all supported proxies" is selected, so that the proxy can be switched easily. + 'http_basic_auth_password' => 'string|max:72', 'include_source_commit_in_build' => 'boolean', 'ports_exposes' => 'nullable|string|regex:/^(\d+)(,\d+)*$/', ]; @@ -3347,9 +3376,15 @@ class ApplicationsController extends Controller ], 422); } - $requestHasHttpBasicAuth = $request->has('is_http_basic_auth_enabled') - || $request->has('http_basic_auth_username') - || $request->has('http_basic_auth_password'); + $requestHasHttpBasicAuth = $request->hasAny([ + 'is_http_basic_auth_enabled', + 'http_basic_auth_username', + 'http_basic_auth_password', + 'http_basic_auth_hash_algorithm', + 'http_basic_auth_bcrypt_cost', + 'http_basic_auth_argon2id_memory_cost', + 'http_basic_auth_argon2id_time_cost', + ]); if ($request->has('is_http_basic_auth_enabled') && $request->is_http_basic_auth_enabled === true) { if (blank($application->http_basic_auth_username) || blank($application->http_basic_auth_password)) { @@ -4894,6 +4929,15 @@ class ApplicationsController extends Controller { $teamId = getTeamIdFromToken(); + if ($request->input('http_basic_auth_hash_algorithm') === HttpBasicAuthHashAlgorithm::ARGON2ID->value && ! $server->caddySupportsArgon2idBasicAuth()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'http_basic_auth_hash_algorithm' => 'Argon2id needs a server that runs the Caddy proxy, version 2.11 or newer.', + ], + ], 422); + } + // Validate ports_mappings if ($request->has('ports_mappings')) { $ports = []; diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php index ad699e8f12..eca3397493 100644 --- a/app/Http/Controllers/Api/CloudProviderTokensController.php +++ b/app/Http/Controllers/Api/CloudProviderTokensController.php @@ -45,6 +45,10 @@ class CloudProviderTokensController extends Controller 'vultr' => Http::withHeaders([ 'Authorization' => 'Bearer '.$token, ])->timeout(10)->get('https://api.vultr.com/v2/account'), + 'hostinger' => Http::withToken($token) + ->acceptJson() + ->timeout(10) + ->get('https://developers.hostinger.com/api/vps/v1/virtual-machines'), default => null, }; @@ -90,7 +94,7 @@ class CloudProviderTokensController extends Controller properties: [ 'uuid' => ['type' => 'string'], 'name' => ['type' => 'string'], - 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr']], + 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr', 'hostinger']], 'team_id' => ['type' => 'integer'], 'servers_count' => ['type' => 'integer'], 'created_at' => ['type' => 'string'], @@ -208,7 +212,7 @@ class CloudProviderTokensController extends Controller type: 'object', required: ['provider', 'token', 'name'], properties: [ - 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr'], 'example' => 'hetzner', 'description' => 'The cloud provider.'], + 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr', 'hostinger'], 'example' => 'hetzner', 'description' => 'The cloud provider.'], 'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'], 'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'], ], @@ -263,7 +267,7 @@ class CloudProviderTokensController extends Controller $body = $request->json()->all(); $validator = customApiValidator($body, [ - 'provider' => 'required|string|in:hetzner,digitalocean,vultr', + 'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger', 'token' => 'required|string', 'name' => 'required|string|max:255', ]); diff --git a/app/Http/Controllers/Api/HostingerController.php b/app/Http/Controllers/Api/HostingerController.php new file mode 100644 index 0000000000..84017e7ded --- /dev/null +++ b/app/Http/Controllers/Api/HostingerController.php @@ -0,0 +1,365 @@ + []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 200, description: 'List of Hostinger VPS data centers.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function dataCenters(Request $request): JsonResponse + { + return $this->providerData($request, 'getDataCenters', 'data centers'); + } + + #[OA\Get( + path: '/hostinger/catalog', + operationId: 'get-hostinger-catalog', + summary: 'Get Hostinger VPS plans and prices', + security: [['bearerAuth' => []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 200, description: 'List of Hostinger VPS plans and prices.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function catalog(Request $request): JsonResponse + { + return $this->providerData($request, 'getCatalogItems', 'catalog'); + } + + #[OA\Get( + path: '/hostinger/templates', + operationId: 'get-hostinger-templates', + summary: 'Get Hostinger VPS operating system templates', + security: [['bearerAuth' => []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 200, description: 'List of Hostinger VPS templates.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function templates(Request $request): JsonResponse + { + return $this->providerData($request, 'getTemplates', 'templates'); + } + + #[OA\Get( + path: '/hostinger/ssh-keys', + operationId: 'get-hostinger-ssh-keys', + summary: 'Get Hostinger account SSH keys', + security: [['bearerAuth' => []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 200, description: 'List of Hostinger account SSH keys.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function sshKeys(Request $request): JsonResponse + { + return $this->providerData($request, 'getPublicKeys', 'SSH keys'); + } + + #[OA\Get( + path: '/hostinger/post-install-scripts', + operationId: 'get-hostinger-post-install-scripts', + summary: 'Get Hostinger post-install scripts', + security: [['bearerAuth' => []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 200, description: 'List of Hostinger post-install scripts.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function postInstallScripts(Request $request): JsonResponse + { + return $this->providerData($request, 'getPostInstallScripts', 'post-install scripts'); + } + + #[OA\Post( + path: '/servers/hostinger', + operationId: 'create-hostinger-server', + summary: 'Purchase a Hostinger VPS and create a linked server', + security: [['bearerAuth' => []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 201, description: 'Hostinger VPS purchased and linked to a Coolify server.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function createServer(Request $request): JsonResponse + { + $allowedFields = [ + 'cloud_provider_token_uuid', + 'cloud_provider_token_id', + 'item_id', + 'data_center_id', + 'template_id', + 'name', + 'private_key_uuid', + 'enable_backups', + 'public_key_ids', + 'post_install_script_id', + 'instant_validate', + ]; + $teamId = getTeamIdFromToken(); + + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $this->authorize('create', [Server::class]); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + 'item_id' => 'required|string', + 'data_center_id' => 'required|integer', + 'template_id' => 'required|integer', + 'name' => ['nullable', 'string', 'max:253', new ValidHostname], + 'private_key_uuid' => 'required|string', + 'enable_backups' => 'nullable|boolean', + 'public_key_ids' => 'nullable|array', + 'public_key_ids.*' => 'integer', + 'post_install_script_id' => 'nullable|integer', + 'instant_validate' => 'nullable|boolean', + ]); + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $team = Team::find($teamId); + if (Team::serverLimitReached($team)) { + return response()->json(['message' => 'Server limit reached for your subscription.'], 400); + } + + $token = $this->hostingerToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first(); + if (! $privateKey) { + return response()->json(['message' => 'Private key not found.'], 404); + } + + $virtualMachineId = null; + + try { + $hostingerService = new HostingerService($token->token); + $normalizedServerName = strtolower(trim($request->name ?: generate_random_name())); + $setup = [ + 'data_center_id' => $request->integer('data_center_id'), + 'template_id' => $request->integer('template_id'), + 'hostname' => $normalizedServerName, + 'enable_backups' => $request->boolean('enable_backups'), + 'public_key' => [ + 'name' => $privateKey->name, + 'key' => $privateKey->getPublicKey(), + ], + ]; + if ($request->filled('post_install_script_id')) { + $setup['post_install_script_id'] = $request->integer('post_install_script_id'); + } + + $virtualMachine = $hostingerService->purchaseVirtualMachine([ + 'item_id' => $request->item_id, + 'setup' => $setup, + ]); + $virtualMachineId = (int) $virtualMachine['id']; + + $server = Server::create([ + 'name' => $normalizedServerName, + 'ip' => Server::PLACEHOLDER_IP, + 'user' => 'root', + 'port' => 22, + 'team_id' => $teamId, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null, + ]); + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + try { + if ($request->array('public_key_ids')) { + $hostingerService->attachPublicKeys((int) $virtualMachine['id'], $request->array('public_key_ids')); + } + $virtualMachine = $hostingerService->waitForPublicIp($virtualMachine); + $ipAddress = $hostingerService->getPublicIpAddress($virtualMachine); + if ($ipAddress) { + $server->update([ + 'ip' => $ipAddress, + 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? $server->hostinger_virtual_machine_status, + ]); + } + } catch (\Throwable $e) { + report($e); + } + + $ipAddress = $server->fresh()->ip; + + if ($request->boolean('instant_validate') && ! $server->hasPlaceholderIp()) { + ValidateServer::dispatch($server); + } + + auditLog('api.hostinger_virtual_machine.created', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'ip' => $ipAddress, + ]); + + return response()->json([ + 'uuid' => $server->uuid, + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'ip' => $ipAddress, + 'provisioning' => $ipAddress === Server::PLACEHOLDER_IP, + ])->setStatusCode(201); + } catch (RateLimitException $e) { + return $this->rateLimitResponse($e); + } catch (\Throwable $e) { + logger()->error('Failed to create Hostinger server', [ + 'error' => $e->getMessage(), + 'team_id' => $teamId, + 'hostinger_virtual_machine_id' => $virtualMachineId, + ]); + + if ($e->getCode() === 202) { + return response()->json(['message' => $e->getMessage()], 202); + } + + return response()->json(array_filter([ + 'message' => $virtualMachineId + ? 'The Hostinger VPS was purchased but could not be saved in Coolify. Manage it in hPanel.' + : 'Failed to create Hostinger server.', + 'hostinger_virtual_machine_id' => $virtualMachineId, + ]), 500); + } + } + + private function providerData(Request $request, string $method, string $resource): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $token = $this->hostingerToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new HostingerService($token->token))->{$method}()); + } catch (RateLimitException $e) { + return $this->rateLimitResponse($e); + } catch (\Throwable $e) { + $hostingerErrorPrefix = 'Hostinger API error: '; + if (in_array($e->getCode(), [401, 403], true) && str_starts_with($e->getMessage(), $hostingerErrorPrefix)) { + $hostingerMessage = substr($e->getMessage(), strlen($hostingerErrorPrefix)); + + return response()->json(['message' => "Hostinger denied access to {$resource}: {$hostingerMessage}"], 403); + } + + return response()->json(['message' => "Failed to fetch Hostinger {$resource}."], 500); + } + } + + private function rateLimitResponse(RateLimitException $e): JsonResponse + { + $response = response()->json(['message' => $e->getMessage()], 429); + if ($e->retryAfter !== null) { + $response->header('Retry-After', $e->retryAfter); + } + + return $response; + } + + private function hostingerToken(Request $request, int $teamId): CloudProviderToken|JsonResponse + { + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $tokenUuid = $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id; + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($tokenUuid) + ->where('provider', 'hostinger') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Hostinger cloud provider token not found.'], 404); + } + + $this->authorize('view', $token); + + return $token; + } +} diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 4c8b6ca3e9..1469adb2c5 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -639,6 +639,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue \Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage()); } + // Domains can change with a deployment, so the maintenance routes follow them. + if ($this->pull_request_id === 0 && $this->application->is_maintenance_enabled) { + try { + $this->application->syncMaintenancePage(); + } catch (Exception $e) { + \Log::warning('Maintenance page update failed for '.$this->deployment_uuid.': '.$e->getMessage()); + } + } + } private function deploy_simple_dockerfile() @@ -3357,12 +3366,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $variables; } + /** + * Without the `...` entry, the list replaces the packages that Railpack generates + * (for example libatomic1, which Node.js 25+ needs at runtime). + */ private function merge_railpack_deploy_apt_packages(Collection $variables): Collection { $packages = collect(preg_split('/\s+/', trim((string) $variables->get('RAILPACK_DEPLOY_APT_PACKAGES', ''))) ?: []) ->filter() ->values(); + if (! $packages->contains('...')) { + $packages->prepend('...'); + } + foreach (['curl', 'wget'] as $package) { if (! $packages->contains($package)) { $packages->push($package); @@ -3446,7 +3463,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ." {$this->workdir}"; return 'DOCKER_CONFIG=/root/.docker docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' - .' && '.$this->wrap_build_command_with_env_export($buildxBuildCommand); + ."\n".railpackBuilderLockedScript('('.$this->wrap_build_command_with_env_export($buildxBuildCommand).')'); } private function decode_railpack_config(string $config, string $source): array diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index 52fa592766..b73c2c5a7a 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -131,8 +131,21 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue ]); } + $maintenanceServers = data_get($this->resource, 'is_maintenance_enabled') ? $this->resource->maintenanceServers() : null; + $this->deleteLocalResource(); + if ($maintenanceServers) { + try { + // Removes the maintenance route of the deleted resource. + $this->resource->syncMaintenancePage($maintenanceServers); + } catch (\Throwable $e) { + Log::warning('Maintenance page cleanup failed while deleting resource.', [ + 'resource_id' => $this->resource->id, + 'error' => $e->getMessage(), + ]); + } + } } private function deleteLocalResource(): void diff --git a/app/Jobs/ServerCloudProviderStatusCheckJob.php b/app/Jobs/ServerCloudProviderStatusCheckJob.php index cd2e51df7d..46d0898c93 100644 --- a/app/Jobs/ServerCloudProviderStatusCheckJob.php +++ b/app/Jobs/ServerCloudProviderStatusCheckJob.php @@ -47,6 +47,9 @@ class ServerCloudProviderStatusCheckJob implements ShouldBeEncrypted, ShouldQueu 'digitalocean' => $this->server->digitalocean_droplet_id ? $this->server->refreshDigitalOceanState() : null, + 'hostinger' => $this->server->hostinger_virtual_machine_id + ? $this->server->refreshHostingerState() + : null, default => null, }; } catch (\Throwable $e) { diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index a8f2a50a31..ac1c551982 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -99,7 +99,8 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue $servers->each(function (Server $server) { $hasCloudResource = $server->hetzner_server_id || $server->vultr_instance_id - || $server->digitalocean_droplet_id; + || $server->digitalocean_droplet_id + || $server->hostinger_virtual_machine_id; if ($hasCloudResource && $server->cloudProviderToken) { ServerCloudProviderStatusCheckJob::dispatch($server); diff --git a/app/Listeners/ProxyStatusChangedNotification.php b/app/Listeners/ProxyStatusChangedNotification.php index 9b117d4e13..74851cee52 100644 --- a/app/Listeners/ProxyStatusChangedNotification.php +++ b/app/Listeners/ProxyStatusChangedNotification.php @@ -33,6 +33,7 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit if ($status === 'running') { $server->setupDefaultRedirect(); + $server->setupMaintenancePages(); $server->setupDynamicProxyConfiguration(); $server->proxy->force_stop = false; $server->save(); diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index cf3aa235df..db00977b8d 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Application; +use App\Enums\HttpBasicAuthHashAlgorithm; use App\Enums\StaticImageTypes; use App\Jobs\ApplicationDeploymentJob; use App\Livewire\Project\Service\Storage; @@ -118,6 +119,14 @@ class General extends Component public ?string $httpBasicAuthPassword = null; + public string $httpBasicAuthHashAlgorithm = HttpBasicAuthHashAlgorithm::BCRYPT->value; + + public int $httpBasicAuthBcryptCost = 10; + + public int $httpBasicAuthArgon2idMemoryCost = 65536; + + public int $httpBasicAuthArgon2idTimeCost = 4; + public ?string $watchPaths = null; public string $redirect; @@ -193,7 +202,13 @@ class General extends Component 'isPreserveRepositoryEnabled' => 'boolean|required', 'isHttpBasicAuthEnabled' => 'boolean|required', 'httpBasicAuthUsername' => 'string|nullable', - 'httpBasicAuthPassword' => 'string|nullable', + // bcrypt ignores everything after 72 bytes. Labels for Traefik always use bcrypt and are also generated on + // Caddy servers when "Labels for all supported proxies" is selected, so that the proxy can be switched easily. + 'httpBasicAuthPassword' => 'string|nullable|max:72', + 'httpBasicAuthHashAlgorithm' => ['required', Rule::enum(HttpBasicAuthHashAlgorithm::class)], + 'httpBasicAuthBcryptCost' => 'required|integer|min:4|max:14', + 'httpBasicAuthArgon2idMemoryCost' => 'required|integer|min:8192|max:262144', + 'httpBasicAuthArgon2idTimeCost' => 'required|integer|min:1|max:12', 'watchPaths' => 'nullable', 'redirect' => 'string|required', ]; @@ -367,6 +382,9 @@ class General extends Component if ($toModel) { $this->validate(); $this->validateChangedDockerComposeRaw(); + if ($this->httpBasicAuthHashAlgorithm === HttpBasicAuthHashAlgorithm::ARGON2ID->value && ! $this->application->destination->server->caddySupportsArgon2idBasicAuth()) { + throw new Exception('Argon2id needs a server that runs the Caddy proxy, version 2.11 or newer.'); + } // Application properties $this->application->name = $this->name; @@ -407,6 +425,10 @@ class General extends Component $this->application->is_http_basic_auth_enabled = $this->isHttpBasicAuthEnabled; $this->application->http_basic_auth_username = $this->httpBasicAuthUsername; $this->application->http_basic_auth_password = $this->httpBasicAuthPassword; + $this->application->http_basic_auth_hash_algorithm = $this->httpBasicAuthHashAlgorithm; + $this->application->http_basic_auth_bcrypt_cost = $this->httpBasicAuthBcryptCost; + $this->application->http_basic_auth_argon2id_memory_cost = $this->httpBasicAuthArgon2idMemoryCost; + $this->application->http_basic_auth_argon2id_time_cost = $this->httpBasicAuthArgon2idTimeCost; $this->application->watch_paths = $this->watchPaths; $this->application->redirect = $this->redirect; @@ -460,6 +482,10 @@ class General extends Component $this->httpBasicAuthPassword = auth()->user()->can('update', $this->application) ? $this->application->http_basic_auth_password : null; + $this->httpBasicAuthHashAlgorithm = ($this->application->usesArgon2idBasicAuth() ? HttpBasicAuthHashAlgorithm::ARGON2ID : HttpBasicAuthHashAlgorithm::BCRYPT)->value; + $this->httpBasicAuthBcryptCost = $this->application->http_basic_auth_bcrypt_cost; + $this->httpBasicAuthArgon2idMemoryCost = $this->application->http_basic_auth_argon2id_memory_cost; + $this->httpBasicAuthArgon2idTimeCost = $this->application->http_basic_auth_argon2id_time_cost; $this->watchPaths = $this->application->watch_paths; $this->redirect = $this->application->redirect; @@ -497,7 +523,7 @@ class General extends Component if ($oldIsSpa !== $this->isSpa) { $this->generateNginxConfiguration($this->isSpa ? 'spa' : 'static'); } - if ($oldIsHttpBasicAuthEnabled !== $this->isHttpBasicAuthEnabled) { + if ($oldIsHttpBasicAuthEnabled !== $this->isHttpBasicAuthEnabled || $this->application->isDirty(['http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost'])) { $this->application->save(); } diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 4de58e145e..0865974828 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared; +use App\Actions\Database\FlushDatabase; use App\Jobs\DeleteResourceJob; use App\Models\Service; use App\Models\ServiceApplication; @@ -157,6 +158,31 @@ class Danger extends Component } } + public function flush($password) + { + try { + $this->authorize('manage', $this->resource); + + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + + FlushDatabase::run($this->resource); + auditLog('ui.database.flushed', [ + 'team_id' => $this->resource->team()?->id, + 'database_uuid' => $this->resource->uuid, + 'database_name' => $this->resource->name, + ]); + $this->dispatch('success', 'Database flushed.'); + + return true; + } catch (\Throwable $e) { + handleError($e, $this); + + return false; + } + } + public function render() { return view('livewire.project.shared.danger', [ diff --git a/app/Livewire/Project/Shared/Maintenance.php b/app/Livewire/Project/Shared/Maintenance.php new file mode 100644 index 0000000000..29c2263e5a --- /dev/null +++ b/app/Livewire/Project/Shared/Maintenance.php @@ -0,0 +1,93 @@ +isMaintenanceEnabled = (bool) $this->resource->is_maintenance_enabled; + $this->maintenancePage = $this->resource->maintenance_page; + } + + /** + * Servers that run the resource with a supported proxy (Traefik or Caddy, not Swarm). + */ + public function getIsSupportedProperty(): bool + { + return $this->resource->maintenanceServers()->contains(fn (Server $server) => ! $server->isSwarm() + && in_array($server->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true)); + } + + public function instantSaveMaintenance(): void + { + try { + $this->authorize('update', $this->resource); + $this->resource->is_maintenance_enabled = $this->isMaintenanceEnabled; + $this->resource->save(); + $this->resource->syncMaintenancePage(); + $this->dispatch('success', $this->isMaintenanceEnabled ? 'Maintenance mode enabled.' : 'Maintenance mode disabled.'); + } catch (\Throwable $e) { + $this->isMaintenanceEnabled = (bool) $this->resource->fresh()?->is_maintenance_enabled; + handleError($e, $this); + } + } + + public function submit(): void + { + try { + $this->authorize('update', $this->resource); + $this->validate([ + 'maintenancePage' => ['nullable', 'string', proxyPageSizeRule('maintenance page')], + ]); + $this->resource->maintenance_page = filled($this->maintenancePage) ? $this->maintenancePage : null; + $this->resource->save(); + if ($this->resource->is_maintenance_enabled) { + $this->resource->syncMaintenancePage(); + } + $this->dispatch('success', 'Maintenance page saved.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function resetMaintenancePage(): void + { + try { + $this->authorize('update', $this->resource); + $this->resource->maintenance_page = null; + $this->resource->save(); + $this->maintenancePage = null; + if ($this->resource->is_maintenance_enabled) { + $this->resource->syncMaintenancePage(); + } + $this->dispatch('success', 'Default maintenance page restored.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.project.shared.maintenance'); + } +} diff --git a/app/Livewire/Security/CloudProviderToken/Show.php b/app/Livewire/Security/CloudProviderToken/Show.php index d8006f7740..f2389628bd 100644 --- a/app/Livewire/Security/CloudProviderToken/Show.php +++ b/app/Livewire/Security/CloudProviderToken/Show.php @@ -85,6 +85,7 @@ class Show extends Component 'hetzner' => $this->validateHetznerToken($this->cloudProviderToken->token), 'digitalocean' => $this->validateDigitalOceanToken($this->cloudProviderToken->token), 'vultr' => $this->validateVultrToken($this->cloudProviderToken->token), + 'hostinger' => $this->validateHostingerToken($this->cloudProviderToken->token), default => false, }; @@ -141,6 +142,7 @@ class Show extends Component return match ($this->cloudProviderToken->provider) { 'digitalocean' => 'DigitalOcean', 'vultr' => 'Vultr', + 'hostinger' => 'Hostinger', default => 'Hetzner', }; } @@ -181,6 +183,19 @@ class Show extends Component } } + private function validateHostingerToken(string $token): bool + { + try { + return Http::withToken($token) + ->acceptJson() + ->timeout(10) + ->get('https://developers.hostinger.com/api/vps/v1/virtual-machines') + ->successful(); + } catch (\Throwable) { + return false; + } + } + public function render() { return view('livewire.security.cloud-provider-token.show'); diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index 2c31d22035..4bdf3f5f3a 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -5,6 +5,7 @@ namespace App\Livewire\Security; use App\Livewire\Server\CloudProviderToken\Show as ServerCloudProviderTokenShow; use App\Livewire\Server\New\ByDigitalOcean; use App\Livewire\Server\New\ByHetzner; +use App\Livewire\Server\New\ByHostinger; use App\Models\CloudProviderToken; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Http; @@ -41,7 +42,7 @@ class CloudProviderTokenForm extends Component protected function rules(): array { return [ - 'provider' => 'required|string|in:hetzner,digitalocean,vultr', + 'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger', 'token' => 'required|string', 'name' => 'required|string|max:255', 'description' => 'nullable|string|max:1000', @@ -86,6 +87,15 @@ class CloudProviderTokenForm extends Component return $response->successful(); } + if ($provider === 'hostinger') { + $response = Http::withToken($token) + ->acceptJson() + ->timeout(10) + ->get('https://developers.hostinger.com/api/vps/v1/virtual-machines'); + + return $response->successful(); + } + return false; } catch (\Throwable $e) { return false; @@ -128,6 +138,7 @@ class CloudProviderTokenForm extends Component if ($savedToken->provider === 'digitalocean') { $this->dispatch('tokenAdded.digitalocean', tokenId: $savedToken->id)->to(ByDigitalOcean::class); + $this->dispatch('tokenAdded.digitalocean', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class); } if ($savedToken->provider === 'hetzner') { @@ -135,6 +146,11 @@ class CloudProviderTokenForm extends Component $this->dispatch('tokenAdded.hetzner', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class); } + if ($savedToken->provider === 'hostinger') { + $this->dispatch('tokenAdded.hostinger', tokenId: $savedToken->id)->to(ByHostinger::class); + $this->dispatch('tokenAdded.hostinger', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class); + } + if ($this->modal_mode) { $this->dispatch('close-modal'); } diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php index d16c729fca..549576ff6f 100644 --- a/app/Livewire/Security/CloudProviderTokens.php +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -59,6 +59,13 @@ class CloudProviderTokens extends Component } else { $this->dispatch('error', 'Vultr token validation failed. Please check the token.'); } + } elseif ($token->provider === 'hostinger') { + $isValid = $this->validateHostingerToken($token->token); + if ($isValid) { + $this->dispatch('success', 'Hostinger token is valid.'); + } else { + $this->dispatch('error', 'Hostinger token validation failed. Please check the token.'); + } } else { $this->dispatch('error', 'Unknown provider.'); } @@ -114,6 +121,19 @@ class CloudProviderTokens extends Component } } + private function validateHostingerToken(string $token): bool + { + try { + return Http::withToken($token) + ->acceptJson() + ->timeout(10) + ->get('https://developers.hostinger.com/api/vps/v1/virtual-machines') + ->successful(); + } catch (\Throwable) { + return false; + } + } + public function deleteToken(int $tokenId) { try { diff --git a/app/Livewire/Server/CloudProviderToken/Show.php b/app/Livewire/Server/CloudProviderToken/Show.php index e8216d0549..9d27340013 100644 --- a/app/Livewire/Server/CloudProviderToken/Show.php +++ b/app/Livewire/Server/CloudProviderToken/Show.php @@ -36,13 +36,25 @@ class Show extends Component { return [ 'tokenAdded.hetzner' => 'handleTokenAdded', + 'tokenAdded.digitalocean' => 'handleTokenAdded', + 'tokenAdded.hostinger' => 'handleTokenAdded', ]; } public function loadTokens() { - $this->provider = $this->server->vultr_instance_id ? 'vultr' : 'hetzner'; - $this->providerName = $this->provider === 'vultr' ? 'Vultr' : 'Hetzner'; + $this->provider = match (true) { + filled($this->server->vultr_instance_id) => 'vultr', + filled($this->server->digitalocean_droplet_id) => 'digitalocean', + filled($this->server->hostinger_virtual_machine_id) => 'hostinger', + default => 'hetzner', + }; + $this->providerName = match ($this->provider) { + 'vultr' => 'Vultr', + 'digitalocean' => 'DigitalOcean', + 'hostinger' => 'Hostinger', + default => 'Hetzner', + }; $this->cloudProviderTokens = CloudProviderToken::where('team_id', $this->server->team_id) ->where('provider', $this->provider) @@ -96,9 +108,7 @@ class Show extends Component private function validateTokenForServer(CloudProviderToken $token): array { try { - $endpoint = $token->provider === 'vultr' - ? 'https://api.vultr.com/v2/account' - : 'https://api.hetzner.cloud/v1/servers'; + $endpoint = $this->validationEndpoint($token->provider); $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, @@ -137,6 +147,33 @@ class Show extends Component } } + if ($this->server->digitalocean_droplet_id) { + $serverResponse = Http::withToken($token->token) + ->timeout(10) + ->get("https://api.digitalocean.com/v2/droplets/{$this->server->digitalocean_droplet_id}"); + + if (! $serverResponse->successful()) { + return [ + 'valid' => false, + 'error' => 'This token cannot access this droplet. It may belong to a different DigitalOcean account.', + ]; + } + } + + if ($this->server->hostinger_virtual_machine_id) { + $serverResponse = Http::withToken($token->token) + ->acceptJson() + ->timeout(10) + ->get("https://developers.hostinger.com/api/vps/v1/virtual-machines/{$this->server->hostinger_virtual_machine_id}"); + + if (! $serverResponse->successful()) { + return [ + 'valid' => false, + 'error' => 'This token cannot access this VPS. It may belong to a different Hostinger account.', + ]; + } + } + return ['valid' => true]; } catch (\Throwable $e) { return [ @@ -156,9 +193,7 @@ class Show extends Component return; } - $endpoint = $token->provider === 'vultr' - ? 'https://api.vultr.com/v2/account' - : 'https://api.hetzner.cloud/v1/servers'; + $endpoint = $this->validationEndpoint($token->provider); $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, @@ -184,6 +219,16 @@ class Show extends Component } } + private function validationEndpoint(string $provider): string + { + return match ($provider) { + 'vultr' => 'https://api.vultr.com/v2/account', + 'digitalocean' => 'https://api.digitalocean.com/v2/account', + 'hostinger' => 'https://developers.hostinger.com/api/vps/v1/virtual-machines', + default => 'https://api.hetzner.cloud/v1/servers', + }; + } + public function render() { return view('livewire.server.cloud-provider-token.show'); diff --git a/app/Livewire/Server/Create.php b/app/Livewire/Server/Create.php index 7edfcaf72b..1f1aae51ea 100644 --- a/app/Livewire/Server/Create.php +++ b/app/Livewire/Server/Create.php @@ -21,7 +21,7 @@ class Create extends Component public function mount(?string $selectedType = null, ?string $selectedTokenUuid = null): void { - $this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'manual'], true) + $this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual'], true) ? $selectedType : null; $this->selectedTokenUuid = $this->selectedType && $this->selectedType !== 'manual' ? $selectedTokenUuid : null; diff --git a/app/Livewire/Server/CreatePage.php b/app/Livewire/Server/CreatePage.php index 158f28351b..be901b9db6 100644 --- a/app/Livewire/Server/CreatePage.php +++ b/app/Livewire/Server/CreatePage.php @@ -28,12 +28,14 @@ class CreatePage extends Component 'hetzner' => 'hetzner', 'vultr' => 'vultr', 'digital-ocean' => 'digitalocean', + 'hostinger' => 'hostinger', default => null, }; $this->tokenProviderName = match ($type) { 'hetzner' => 'Hetzner', 'vultr' => 'Vultr', 'digital-ocean' => 'DigitalOcean', + 'hostinger' => 'Hostinger', default => null, }; $this->hasProviderTokens = $this->tokenProvider @@ -43,6 +45,7 @@ class CreatePage extends Component 'hetzner' => 'Hetzner', 'vultr' => 'Vultr', 'digital-ocean' => 'DigitalOcean', + 'hostinger' => 'Hostinger', 'manual' => 'Manual', default => 'New Server', }; diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index 359f94e0fb..c3539aa282 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -20,6 +20,8 @@ class Delete extends Component public bool $delete_from_digitalocean = false; + public bool $delete_from_hostinger = false; + public bool $force_delete_resources = false; public function mount(string $server_uuid) @@ -41,6 +43,7 @@ class Delete extends Component $this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions); $this->delete_from_vultr = in_array('delete_from_vultr', $selectedActions); $this->delete_from_digitalocean = in_array('delete_from_digitalocean', $selectedActions); + $this->delete_from_hostinger = in_array('delete_from_hostinger', $selectedActions); $this->force_delete_resources = in_array('force_delete_resources', $selectedActions); } try { @@ -72,7 +75,9 @@ class Delete extends Component $this->delete_from_vultr, $this->server->vultr_instance_id, $this->delete_from_digitalocean, - $this->server->digitalocean_droplet_id + $this->server->digitalocean_droplet_id, + $this->delete_from_hostinger, + $this->server->hostinger_virtual_machine_id ); return redirectRoute($this, 'server.index'); @@ -118,6 +123,14 @@ class Delete extends Component ]; } + if ($this->server->hostinger_virtual_machine_id) { + $checkboxes[] = [ + 'id' => 'delete_from_hostinger', + 'label' => 'Also delete VPS from Hostinger (at the end of the paid period)', + 'default_warning' => 'The VPS on Hostinger will NOT be deleted, and Hostinger will keep billing for it.', + ]; + } + return view('livewire.server.delete', [ 'checkboxes' => $checkboxes, ]); diff --git a/app/Livewire/Server/New/ByHostinger.php b/app/Livewire/Server/New/ByHostinger.php new file mode 100644 index 0000000000..c05fcf7d5d --- /dev/null +++ b/app/Livewire/Server/New/ByHostinger.php @@ -0,0 +1,514 @@ +authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + $this->selectTokenFromUrl($selectedTokenUuid); + $this->server_name = generate_random_name(); + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + + if ($this->private_keys->isNotEmpty()) { + $this->private_key_id = $this->private_keys->first()->id; + } + + if ($this->selectedTokenUuid) { + $this->current_step = 2; + $this->loading_data = true; + } + + return null; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function getListeners(): array + { + return [ + 'tokenAdded.hostinger' => 'handleTokenAdded', + 'privateKeyCreated' => 'handlePrivateKeyCreated', + 'modalClosed' => 'resetSelection', + ]; + } + + protected function rules(): array + { + $rules = [ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]; + + if ($this->current_step === 2) { + $rules = array_merge($rules, [ + 'server_name' => ['required', 'string', 'max:253', new ValidHostname], + 'selected_data_center_id' => 'required|integer', + 'selected_template_id' => 'required|integer', + 'selected_price_id' => 'required|string', + 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, + 'enable_backups' => 'required|boolean', + 'selected_public_key_ids' => 'array', + 'selected_public_key_ids.*' => 'integer', + 'selected_post_install_script_id' => 'nullable|integer', + ]); + } + + return $rules; + } + + protected function messages(): array + { + return [ + 'selected_token_id.required' => 'Please select a Hostinger token.', + 'selected_token_id.exists' => 'Selected token not found.', + ]; + } + + public function loadTokens(): void + { + $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hostinger') + ->get(); + } + + public function handleTokenAdded(int $tokenId): mixed + { + $this->loadTokens(); + $this->selected_token_id = $tokenId; + + return $this->nextStep(); + } + + public function handlePrivateKeyCreated(int $keyId): void + { + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + $this->private_key_id = $keyId; + $this->resetErrorBag('private_key_id'); + } + + public function resetSelection(): void + { + $this->selected_token_id = null; + $this->current_step = 1; + } + + public function nextStep(): mixed + { + $this->validate([ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]); + + try { + if (! $this->selectedTokenUuid) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + if ($token) { + return $this->redirectRoute('server.create.token', [ + 'type' => 'hostinger', + 'token_uuid' => $token->uuid, + ], navigate: true); + } + } + + $this->current_step = 2; + $this->loading_data = true; + + return null; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function loadHostingerData(): void + { + $token = $this->getHostingerToken(); + + if (! $token) { + $this->loading_data = false; + $this->dispatch('error', 'Please select a valid Hostinger token.'); + + return; + } + + $this->loading_data = true; + $this->provider_data_error = null; + + try { + $hostingerService = new HostingerService($token); + $this->data_centers = $hostingerService->getDataCenters(); + $this->templates = collect($hostingerService->getTemplates()) + ->sortBy('name') + ->values() + ->toArray(); + $this->catalog_items = $hostingerService->getCatalogItems(); + $this->hostinger_public_keys = $this->optionalProviderData(fn () => $hostingerService->getPublicKeys()); + $this->post_install_scripts = $this->optionalProviderData(fn () => $hostingerService->getPostInstallScripts()); + $this->selectDefaultTemplate(); + } catch (\Throwable $e) { + $this->provider_data_error = $e->getMessage(); + $this->dispatch('error', $this->provider_data_error); + } finally { + $this->loading_data = false; + } + } + + public function updatedSelectedOsTemplateId(): void + { + if ($this->selected_os_template_id !== null) { + $this->selected_app_template_id = null; + } + + $this->selected_template_id = $this->selected_os_template_id ?? $this->selected_app_template_id; + } + + public function updatedSelectedAppTemplateId(): void + { + if ($this->selected_app_template_id !== null) { + $this->selected_os_template_id = null; + } + + $this->selected_template_id = $this->selected_app_template_id ?? $this->selected_os_template_id; + } + + /** + * Plain operating system templates. + */ + public function getOsTemplatesProperty(): array + { + return collect($this->templates) + ->reject(fn (array $template): bool => $this->templateIncludesApplication($template)) + ->values() + ->all(); + } + + /** + * Operating system templates that also install an application or control panel. + */ + public function getAppTemplatesProperty(): array + { + return collect($this->templates) + ->filter(fn (array $template): bool => $this->templateIncludesApplication($template)) + ->values() + ->all(); + } + + public function getPriceOptionsProperty(): array + { + return collect($this->catalog_items) + ->flatMap(function (array $item): array { + $planSize = $this->planSize($item); + + return collect($item['prices'] ?? []) + ->map(fn (array $price): array => array_merge($price, [ + 'plan_name' => $item['name'] ?? $item['id'] ?? 'VPS', + 'plan_size' => $planSize, + ])) + ->all(); + }) + ->sort(fn (array $a, array $b): int => [$a['plan_size'], $this->periodInMonths($a)] <=> [$b['plan_size'], $this->periodInMonths($b)]) + ->values() + ->toArray(); + } + + public function getSelectedPriceProperty(): ?array + { + return collect($this->priceOptions)->firstWhere('id', $this->selected_price_id); + } + + public function priceLabel(array $price): string + { + $currency = strtoupper($price['currency'] ?? 'USD'); + $firstPeriodPrice = (int) ($price['first_period_price'] ?? $price['price'] ?? 0); + $renewalPrice = (int) ($price['price'] ?? $firstPeriodPrice); + $period = (int) ($price['period'] ?? 1); + $periodUnit = $price['period_unit'] ?? 'month'; + $periodLabel = $period.' '.str($periodUnit)->plural($period); + + return sprintf( + '%s — %s %.2f for %s, then %s %.2f', + $price['plan_name'] ?? 'VPS', + $currency, + $firstPeriodPrice / 100, + $periodLabel, + $currency, + $renewalPrice / 100 + ); + } + + public function submit(): mixed + { + $this->validate(); + + $virtualMachineId = null; + + try { + $this->authorize('create', Server::class); + + if (Team::serverLimitReached()) { + return $this->dispatch('error', 'You have reached the server limit for your subscription.'); + } + + $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); + $hostingerService = new HostingerService($this->getHostingerToken()); + + if (! $this->providerSelectionsAreValid($hostingerService)) { + return null; + } + + $normalizedServerName = strtolower(trim($this->server_name)); + $setup = [ + 'data_center_id' => $this->selected_data_center_id, + 'template_id' => $this->selected_template_id, + 'hostname' => $normalizedServerName, + 'enable_backups' => $this->enable_backups, + 'public_key' => [ + 'name' => $privateKey->name, + 'key' => $privateKey->getPublicKey(), + ], + ]; + if ($this->selected_post_install_script_id) { + $setup['post_install_script_id'] = $this->selected_post_install_script_id; + } + + $virtualMachine = $hostingerService->purchaseVirtualMachine([ + 'item_id' => $this->selected_price_id, + 'setup' => $setup, + ]); + $virtualMachineId = (int) $virtualMachine['id']; + + $server = Server::create([ + 'name' => $normalizedServerName, + 'ip' => Server::PLACEHOLDER_IP, + 'user' => 'root', + 'port' => 22, + 'team_id' => currentTeam()->id, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $this->selected_token_id, + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null, + ]); + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + try { + if ($this->selected_public_key_ids) { + $hostingerService->attachPublicKeys((int) $virtualMachine['id'], $this->selected_public_key_ids); + } + $virtualMachine = $hostingerService->waitForPublicIp($virtualMachine); + $ipAddress = $hostingerService->getPublicIpAddress($virtualMachine); + if ($ipAddress) { + $server->update([ + 'ip' => $ipAddress, + 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? $server->hostinger_virtual_machine_status, + ]); + } + } catch (\Throwable $e) { + report($e); + } + + if ($this->from_onboarding) { + currentTeam()->update(['show_boarding' => false]); + refreshSession(); + } + + return redirectRoute($this, 'server.show', [$server->uuid]); + } catch (\Throwable $e) { + if ($virtualMachineId) { + logger()->warning('Hostinger VPS was purchased but could not be saved in Coolify', [ + 'team_id' => currentTeam()->id, + 'hostinger_virtual_machine_id' => $virtualMachineId, + ]); + $this->dispatch('error', "Hostinger VPS {$virtualMachineId} was purchased but could not be saved in Coolify. Manage it in hPanel."); + } + + return handleError($e, $this); + } + } + + private function selectTokenFromUrl(?string $selectedTokenUuid): void + { + if (! $selectedTokenUuid) { + return; + } + + $token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid); + + if ($token) { + $this->selectedTokenUuid = $selectedTokenUuid; + $this->selected_token_id = $token->id; + } + } + + private function getHostingerToken(): string + { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + return $token?->token ?? ''; + } + + private function selectDefaultTemplate(): void + { + if ($this->selected_template_id !== null) { + return; + } + + $defaultTemplate = collect($this->osTemplates)->firstWhere('name', 'Ubuntu 24.04 LTS'); + + if ($defaultTemplate) { + $this->selected_os_template_id = (int) $defaultTemplate['id']; + $this->selected_template_id = $this->selected_os_template_id; + } + } + + private function templateIncludesApplication(array $template): bool + { + return str_contains((string) ($template['name'] ?? ''), ' with '); + } + + /** + * The plan size from the KVM plan id (hostingercom-vps-kvm4 is 4), falling back to its CPU count. + */ + private function planSize(array $item): int + { + if (preg_match('/-vps-kvm(\d+)$/', (string) ($item['id'] ?? ''), $matches) === 1) { + return (int) $matches[1]; + } + + return (int) ($item['metadata']['cpus'] ?? 0); + } + + private function periodInMonths(array $price): float + { + $period = (int) ($price['period'] ?? 0); + + return match ($price['period_unit'] ?? 'month') { + 'day' => $period / 30, + 'week' => $period * 7 / 30, + 'year' => $period * 12, + 'none' => PHP_INT_MAX, + default => $period, + }; + } + + /** + * Load an optional Hostinger list. Some tokens cannot read these lists, so a failure must not block the form. + */ + private function optionalProviderData(callable $callback): array + { + try { + return $callback(); + } catch (\Throwable $e) { + report($e); + + return []; + } + } + + private function providerSelectionsAreValid(HostingerService $hostingerService): bool + { + $priceIds = collect($hostingerService->getCatalogItems()) + ->flatMap(fn (array $item) => collect($item['prices'] ?? [])->pluck('id')); + $dataCenterIds = collect($hostingerService->getDataCenters())->pluck('id')->map(fn ($id) => (int) $id); + $templateIds = collect($hostingerService->getTemplates())->pluck('id')->map(fn ($id) => (int) $id); + $publicKeyIds = collect($this->selected_public_key_ids ? $hostingerService->getPublicKeys() : [])->pluck('id')->map(fn ($id) => (int) $id); + $postInstallScriptIds = collect($this->selected_post_install_script_id ? $hostingerService->getPostInstallScripts() : [])->pluck('id')->map(fn ($id) => (int) $id); + + if (! $priceIds->contains($this->selected_price_id)) { + $this->addError('selected_price_id', 'The selected Hostinger plan or billing period is no longer available.'); + } + + if (! $dataCenterIds->contains($this->selected_data_center_id)) { + $this->addError('selected_data_center_id', 'The selected Hostinger data center is no longer available.'); + } + + if (! $templateIds->contains($this->selected_template_id)) { + $this->addError('selected_template_id', 'The selected Hostinger operating system is no longer available.'); + } + + if (collect($this->selected_public_key_ids)->contains(fn ($id) => ! $publicKeyIds->contains((int) $id))) { + $this->addError('selected_public_key_ids', 'One or more selected Hostinger SSH keys are no longer available.'); + } + + if ($this->selected_post_install_script_id && ! $postInstallScriptIds->contains($this->selected_post_install_script_id)) { + $this->addError('selected_post_install_script_id', 'The selected Hostinger post-install script is no longer available.'); + } + + return ! $this->getErrorBag()->isNotEmpty(); + } + + public function render() + { + return view('livewire.server.new.by-hostinger'); + } +} diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index b1a37f4846..6b7a3859e5 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -26,6 +26,8 @@ class Proxy extends Component public ?string $redirectUrl = null; + public ?string $customErrorPage = null; + public bool $generateExactLabels = false; /** @@ -49,6 +51,7 @@ class Proxy extends Component return [ 'generateExactLabels' => 'required|boolean', 'redirectUrl' => ['nullable', new SafeExternalUrl], + 'customErrorPage' => ['nullable', 'string', proxyPageSizeRule('custom error page')], ]; } @@ -57,6 +60,7 @@ class Proxy extends Component $this->selectedProxy = $this->server->proxyType(); $this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true); $this->redirectUrl = data_get($this->server, 'proxy.redirect_url'); + $this->customErrorPage = $this->server->proxy_error_page; $this->syncData(false); $this->clearAppliedTraefikBranchWarning(); } @@ -187,6 +191,20 @@ class Proxy extends Component } } + public function resetCustomErrorPage(): void + { + try { + $this->authorize('update', $this->server); + $this->server->proxy_error_page = null; + $this->server->save(); + $this->customErrorPage = null; + $this->server->setupDefaultRedirect(); + $this->dispatch('success', 'Default error page restored.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function submit() { try { @@ -194,6 +212,7 @@ class Proxy extends Component $this->validate(); SaveProxyConfiguration::run($this->server, $this->proxySettings); $this->server->proxy->redirect_url = $this->redirectUrl; + $this->server->proxy_error_page = filled($this->customErrorPage) ? $this->customErrorPage : null; $this->server->save(); auditLog('ui.server.proxy.configuration_saved', $this->proxyAuditContext()); $this->server->setupDefaultRedirect(); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 8357e89257..839f545ae2 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -10,6 +10,7 @@ use App\Models\Server; use App\Rules\ValidServerIp; use App\Services\DigitalOceanService; use App\Services\HetznerService; +use App\Services\HostingerService; use App\Services\ServerTransfer\ServerTransferClaimer; use App\Services\VultrService; use App\Support\ValidationPatterns; @@ -82,12 +83,16 @@ class Show extends Component public ?string $digitalOceanDropletStatus = null; + public ?string $hostingerVirtualMachineStatus = null; + public bool $hetznerServerManuallyStarted = false; public bool $vultrInstanceManuallyStarted = false; public bool $digitalOceanDropletManuallyStarted = false; + public bool $hostingerVirtualMachineManuallyStarted = false; + public bool $isValidating = false; // Hetzner linking properties @@ -127,6 +132,18 @@ class Show extends Component public bool $digitalOceanNoMatchFound = false; + public Collection $availableHostingerTokens; + + public ?int $selectedHostingerTokenId = null; + + public ?string $manualHostingerVirtualMachineId = null; + + public ?array $matchedHostingerVirtualMachine = null; + + public ?string $hostingerSearchError = null; + + public bool $hostingerNoMatchFound = false; + public function getListeners() { $teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id; @@ -206,12 +223,14 @@ class Show extends Component $this->hetznerServerStatus = $this->server->hetzner_server_status; $this->vultrInstanceStatus = $this->server->vultr_instance_status; $this->digitalOceanDropletStatus = $this->server->digitalocean_droplet_status; + $this->hostingerVirtualMachineStatus = $this->server->hostinger_virtual_machine_status; $this->isValidating = $this->server->is_validating ?? false; // Load cloud provider tokens for linking $this->loadHetznerTokens(); $this->loadVultrTokens(); $this->loadDigitalOceanTokens(); + $this->loadHostingerTokens(); } catch (\Throwable $e) { return handleError($e, $this); @@ -658,6 +677,30 @@ class Show extends Component } } + public function checkHostingerVirtualMachineStatus(bool $manual = false): mixed + { + try { + $this->authorize('view', $this->server); + if (! $this->server->hostinger_virtual_machine_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a Hostinger VPS or token.'); + + return null; + } + + $this->hostingerVirtualMachineStatus = $this->server->refreshHostingerState(); + $this->server->refresh(); + $this->ip = $this->server->ip; + + if ($manual) { + $this->dispatch('success', 'VPS status refreshed: '.ucfirst($this->hostingerVirtualMachineStatus ?? 'unknown')); + } + + return null; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function handleServerValidated($event = null) { // Check if event is for this server @@ -679,6 +722,7 @@ class Show extends Component $this->loadHetznerTokens(); $this->loadVultrTokens(); $this->loadDigitalOceanTokens(); + $this->loadHostingerTokens(); $this->dispatch('refreshServerShow'); $this->dispatch('refreshServer'); @@ -750,6 +794,30 @@ class Show extends Component } } + public function startHostingerVirtualMachine(): mixed + { + try { + $this->authorize('update', $this->server); + if (! $this->server->hostinger_virtual_machine_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a Hostinger VPS or token.'); + + return null; + } + + $hostingerService = new HostingerService($this->server->cloudProviderToken->token); + $hostingerService->startVirtualMachine((int) $this->server->hostinger_virtual_machine_id); + + $this->hostingerVirtualMachineStatus = 'starting'; + $this->server->update(['hostinger_virtual_machine_status' => 'starting']); + $this->hostingerVirtualMachineManuallyStarted = true; + $this->dispatch('success', 'Hostinger VPS is starting...'); + + return null; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function refreshServerMetadata(): void { try { @@ -804,6 +872,13 @@ class Show extends Component ->get(); } + public function loadHostingerTokens(): void + { + $this->availableHostingerTokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hostinger') + ->get(); + } + #[Computed] public function limaStartCommand(): ?string { @@ -1077,6 +1152,136 @@ class Show extends Component } } + public function searchHostingerVirtualMachine(): void + { + $this->hostingerSearchError = null; + $this->hostingerNoMatchFound = false; + $this->matchedHostingerVirtualMachine = null; + + if (! $this->selectedHostingerTokenId) { + $this->hostingerSearchError = 'Please select a Hostinger token.'; + + return; + } + + try { + $this->authorize('update', $this->server); + $token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId); + + if (! $token) { + $this->hostingerSearchError = 'Invalid token selected.'; + + return; + } + + $matched = (new HostingerService($token->token))->findVirtualMachineByIp($this->server->ip); + + if ($matched) { + $this->matchedHostingerVirtualMachine = $matched; + } else { + $this->hostingerNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->hostingerSearchError = 'Failed to search Hostinger virtual machines: '.$e->getMessage(); + } + } + + public function searchHostingerVirtualMachineById(): void + { + $this->hostingerSearchError = null; + $this->hostingerNoMatchFound = false; + $this->matchedHostingerVirtualMachine = null; + + if (! $this->selectedHostingerTokenId) { + $this->hostingerSearchError = 'Please select a Hostinger token first.'; + + return; + } + + if (! $this->manualHostingerVirtualMachineId) { + $this->hostingerSearchError = 'Please enter a Hostinger Virtual Machine ID.'; + + return; + } + + try { + $this->authorize('update', $this->server); + $token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId); + + if (! $token) { + $this->hostingerSearchError = 'Invalid token selected.'; + + return; + } + + $virtualMachine = (new HostingerService($token->token)) + ->getVirtualMachine((int) $this->manualHostingerVirtualMachineId); + + if ($virtualMachine) { + $this->matchedHostingerVirtualMachine = $virtualMachine; + } else { + $this->hostingerNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->hostingerSearchError = 'Failed to fetch Hostinger virtual machine: '.$e->getMessage(); + } + } + + public function linkToHostinger(): mixed + { + if (! $this->matchedHostingerVirtualMachine) { + $this->dispatch('error', 'No Hostinger virtual machine selected.'); + + return null; + } + + try { + $this->authorize('update', $this->server); + $token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId); + + if (! $token) { + $this->dispatch('error', 'Invalid token selected.'); + + return null; + } + + $hostingerService = new HostingerService($token->token); + $virtualMachine = $hostingerService->getVirtualMachine((int) $this->matchedHostingerVirtualMachine['id']); + + if (! $virtualMachine) { + $this->dispatch('error', 'Could not find Hostinger virtual machine with ID: '.$this->matchedHostingerVirtualMachine['id']); + + return null; + } + + $updates = [ + 'cloud_provider_token_id' => $this->selectedHostingerTokenId, + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null, + ]; + $ip = $hostingerService->getPublicIpAddress($virtualMachine); + if ($ip) { + $updates['ip'] = $ip; + } + + $this->server->update($updates); + $this->hostingerVirtualMachineStatus = $virtualMachine['state'] ?? null; + $this->matchedHostingerVirtualMachine = null; + $this->selectedHostingerTokenId = null; + $this->manualHostingerVirtualMachineId = null; + $this->hostingerNoMatchFound = false; + $this->hostingerSearchError = null; + + $this->dispatch('success', 'Server successfully linked to Hostinger!'); + $this->dispatch('close-modal'); + $this->dispatch('refreshServerShow'); + + return null; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function searchVultrInstance(): void { $this->vultrSearchError = null; diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index 33b77418d6..407f5f31d2 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -145,6 +145,18 @@ class ValidateAndInstall extends Component } } + if ($this->server->hostinger_virtual_machine_id) { + $status = $this->server->refreshHostingerState(); + $this->server->refresh(); + + if (in_array($status, ['stopping', 'stopped', 'suspending', 'suspended', 'destroying', 'destroyed', 'error'], true)) { + $this->error = 'Hostinger VPS is '.($status ?? 'not running').'. Power it on before validating.'; + $this->server->update(['validation_logs' => $this->error]); + + return; + } + } + ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); if (! $this->uptime) { $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php index 280e26b9e7..f6311f22bb 100644 --- a/app/Mcp/Concerns/BuildsResponse.php +++ b/app/Mcp/Concerns/BuildsResponse.php @@ -24,7 +24,7 @@ trait BuildsResponse // raw IDs / morph types (uuid is the public identifier) 'id', 'team_id', 'tokenable_id', 'tokenable_type', 'server_id', 'private_key_id', 'cloud_provider_token_id', - 'hetzner_server_id', 'digitalocean_droplet_id', 'environment_id', 'destination_id', + 'hetzner_server_id', 'digitalocean_droplet_id', 'hostinger_virtual_machine_id', 'environment_id', 'destination_id', 'source_id', 'repository_project_id', 'application_id', 'service_id', 'project_id', 'parent_id', 'resourceable', 'resourceable_id', 'resourceable_type', diff --git a/app/Models/Application.php b/app/Models/Application.php index a1146059d5..3b0ac88ce5 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\ApplicationDeploymentStatus; use App\Enums\BuildPackTypes; +use App\Enums\HttpBasicAuthHashAlgorithm; use App\Enums\ProxyTypes; use App\Exceptions\DeploymentException; use App\Services\ConfigurationGenerator; @@ -16,6 +17,7 @@ use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasComposeVolumeWarnings; use App\Traits\HasConfiguration; +use App\Traits\HasMaintenancePage; use App\Traits\HasMetrics; use App\Traits\HasNoindexDomains; use App\Traits\HasSafeStringAttribute; @@ -125,6 +127,10 @@ use Symfony\Component\Yaml\Yaml; 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], new OA\Property(property: 'settings', ref: '#/components/schemas/ApplicationSetting'), ] )] @@ -132,7 +138,7 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { /** @use HasFactory */ - use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasMaintenancePage, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, SoftDeletes; public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; @@ -220,6 +226,10 @@ class Application extends BaseModel 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', + 'http_basic_auth_hash_algorithm', + 'http_basic_auth_bcrypt_cost', + 'http_basic_auth_argon2id_memory_cost', + 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', @@ -268,6 +278,7 @@ class Application extends BaseModel { return [ 'http_basic_auth_password' => 'encrypted', + 'http_basic_auth_hash_algorithm' => HttpBasicAuthHashAlgorithm::class, 'manual_webhook_secret_github' => 'encrypted', 'manual_webhook_secret_gitlab' => 'encrypted', 'manual_webhook_secret_bitbucket' => 'encrypted', @@ -280,6 +291,7 @@ class Application extends BaseModel 'restart_limit_reached' => 'boolean', 'container_present' => 'boolean', 'last_restart_at' => 'datetime', + 'is_maintenance_enabled' => 'boolean', ]; } @@ -575,6 +587,39 @@ class Application extends BaseModel instant_remote_process(["docker network rm {$uuid}"], $server, false); } + /** + * Domains of the application, or of every service of a Docker Compose application. + * + * @return Collection + */ + public function maintenanceDomains(): Collection + { + if ($this->build_pack === 'dockercompose') { + $urls = collect(json_decode($this->docker_compose_domains ?: '[]', true) ?: []) + ->flatMap(fn ($service) => explode(',', (string) data_get($service, 'domain', ''))); + } else { + $urls = collect($this->fqdns); + } + $forceHttps = (bool) $this->isForceHttpsEnabled(); + + return $urls->map(fn ($url) => trim((string) $url)) + ->filter() + ->map(fn (string $url) => ['url' => $url, 'force_https' => $forceHttps]) + ->values(); + } + + /** + * @return Collection + */ + public function maintenanceServers(): Collection + { + return collect([$this->destination?->server]) + ->concat($this->additional_servers) + ->filter() + ->unique('id') + ->values(); + } + public function additional_servers() { return $this->belongsToMany(Server::class, 'additional_destinations') @@ -657,6 +702,12 @@ class Application extends BaseModel return $this->source instanceof GithubApp; } + public function usesArgon2idBasicAuth(): bool + { + return $this->http_basic_auth_hash_algorithm === HttpBasicAuthHashAlgorithm::ARGON2ID + && $this->destination->server->caddySupportsArgon2idBasicAuth(); + } + public function isForceHttpsEnabled() { return data_get($this, 'settings.is_force_https_enabled', false); diff --git a/app/Models/Server.php b/app/Models/Server.php index 38068b17f6..3ab35001f2 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -21,6 +21,7 @@ use App\Notifications\Server\Reachable; use App\Notifications\Server\Unreachable; use App\Services\DigitalOceanService; use App\Services\HetznerService; +use App\Services\HostingerService; use App\Services\VultrService; use App\Support\ValidationPatterns; use App\Traits\Auditable; @@ -129,6 +130,19 @@ class Server extends BaseModel public const PLACEHOLDER_IPS = [self::PLACEHOLDER_IP, '0.0.0.0', '::']; + /** + * Serves the proxy error pages (503.html) for the Traefik and Caddy catch-all. + */ + public const PROXY_ERROR_PAGE_CONTAINER = 'coolify-proxy-error-pages'; + + public const PROXY_ERROR_PAGE_IMAGE = 'busybox:musl'; + + /** + * Maximum size of a custom error or maintenance page. The page goes to the server with scp + * (see uploadProxyFile()), so this limit only keeps the Livewire payload reasonable. + */ + public const PROXY_ERROR_PAGE_MAX_BYTES = 1048576; + /** * Default Caddy proxy image. caddy-docker-proxy 2.13 ships Caddy 2.11. */ @@ -324,6 +338,7 @@ class Server extends BaseModel protected $hidden = [ 'logdrain_axiom_api_key', 'logdrain_newrelic_license_key', + 'proxy_error_page', ]; protected $schemalessAttributes = [ @@ -345,6 +360,8 @@ class Server extends BaseModel 'vultr_instance_status', 'digitalocean_droplet_id', 'digitalocean_droplet_status', + 'hostinger_virtual_machine_id', + 'hostinger_virtual_machine_status', 'is_validating', 'validation_logs', 'detected_traefik_version', @@ -523,6 +540,27 @@ class Server extends BaseModel return $status; } + public function refreshHostingerState(): ?string + { + if (! $this->hostinger_virtual_machine_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'hostinger') { + return $this->hostinger_virtual_machine_status; + } + + $hostingerService = new HostingerService($this->cloudProviderToken->token); + $virtualMachine = $hostingerService->getVirtualMachine((int) $this->hostinger_virtual_machine_id); + + if (empty($virtualMachine)) { + return $this->hostinger_virtual_machine_status; + } + + $status = $virtualMachine['state'] ?? null; + $ip = $hostingerService->getPublicIpAddress($virtualMachine); + $this->persistProviderState(['hostinger_virtual_machine_status' => $status]); + $this->backfillPlaceholderIp($ip); + + return $status; + } + protected function isCoolifyHost(): Attribute { return Attribute::make( @@ -621,6 +659,10 @@ class Server extends BaseModel return $this->proxyType() && $this->proxyType() !== 'NONE' && $this->isFunctional() && ! $this->isSwarmWorker() && $this->canHostResources(); } + /** + * Writes the catch-all proxy configuration for requests that no resource handles. + * Priority: redirect URL > custom error page > Coolify default error page. + */ public function setupDefaultRedirect() { $banner = @@ -629,7 +671,6 @@ class Server extends BaseModel $dynamic_conf_path = $this->proxyPath().'/dynamic'; $proxy_type = $this->proxyType(); $redirect_enabled = $this->proxy->redirect_enabled ?? true; - $redirect_url = $this->proxy->redirect_url; if ($proxy_type === ProxyTypes::TRAEFIK->value) { $default_redirect_file = "$dynamic_conf_path/default_redirect_503.yaml"; } elseif ($proxy_type === ProxyTypes::CADDY->value) { @@ -641,67 +682,13 @@ class Server extends BaseModel "rm -f $dynamic_conf_path/default_redirect_404.yaml", "rm -f $dynamic_conf_path/default_redirect_404.caddy", ], $this); + // The error page container must run before the catch-all points to it. + $this->setupProxyErrorPageContainer(); if ($redirect_enabled === false) { instant_remote_process(["rm -f $default_redirect_file"], $this); } else { - if ($proxy_type === ProxyTypes::CADDY->value) { - if (filled($redirect_url)) { - $conf = ":80, :443 { - redir $redirect_url -}"; - } else { - $conf = ':80, :443 { - respond 503 -}'; - } - } elseif ($proxy_type === ProxyTypes::TRAEFIK->value) { - $dynamic_conf = [ - 'http' => [ - 'routers' => [ - 'catchall' => [ - 'entryPoints' => [ - 0 => 'http', - 1 => 'https', - ], - 'service' => 'noop', - 'rule' => 'PathPrefix(`/`)', - 'tls' => [ - 'certResolver' => 'letsencrypt', - ], - 'priority' => -1000, - ], - ], - 'services' => [ - 'noop' => [ - 'loadBalancer' => [ - 'servers' => [], - ], - ], - ], - ], - ]; - if (filled($redirect_url)) { - $dynamic_conf['http']['routers']['catchall']['middlewares'] = [ - 0 => 'redirect-regexp', - ]; - - $dynamic_conf['http']['services']['noop']['loadBalancer']['servers'][0] = [ - 'url' => '', - ]; - $dynamic_conf['http']['middlewares'] = [ - 'redirect-regexp' => [ - 'redirectRegex' => [ - 'regex' => '(.*)', - 'replacement' => $redirect_url, - 'permanent' => false, - ], - ], - ]; - } - $conf = Yaml::dump($dynamic_conf, 12, 2); - } - $conf = $banner.$conf; + $conf = $banner.$this->defaultRedirectConfiguration(); $base64 = base64_encode($conf); instant_remote_process([ "echo '$base64' | base64 -d | tee $default_redirect_file > /dev/null", @@ -713,6 +700,348 @@ class Server extends BaseModel } } + /** + * Catch-all proxy configuration (without the banner) for an enabled default redirect. + */ + public function defaultRedirectConfiguration(): string + { + $redirect_url = $this->proxy->redirect_url; + $usesErrorPage = $this->usesProxyErrorPage(); + + if ($this->proxyType() === ProxyTypes::CADDY->value) { + if (filled($redirect_url)) { + return ":80, :443 { + redir $redirect_url +}"; + } + if ($usesErrorPage) { + // busybox httpd answers 200, so copy_response sets the 503 status. + return ':80, :443 { + reverse_proxy '.self::PROXY_ERROR_PAGE_CONTAINER.':80 { + rewrite /503.html + handle_response { + copy_response 503 + } + } +}'; + } + + return ':80, :443 { + respond 503 +}'; + } + + $dynamic_conf = [ + 'http' => [ + 'routers' => [ + 'catchall' => [ + 'entryPoints' => [ + 0 => 'http', + 1 => 'https', + ], + 'service' => 'noop', + 'rule' => 'PathPrefix(`/`)', + 'tls' => [ + 'certResolver' => 'letsencrypt', + ], + 'priority' => -1000, + ], + ], + 'services' => [ + 'noop' => [ + 'loadBalancer' => [ + 'servers' => [], + ], + ], + ], + ], + ]; + if (filled($redirect_url)) { + $dynamic_conf['http']['routers']['catchall']['middlewares'] = [ + 0 => 'redirect-regexp', + ]; + + $dynamic_conf['http']['services']['noop']['loadBalancer']['servers'][0] = [ + 'url' => '', + ]; + $dynamic_conf['http']['middlewares'] = [ + 'redirect-regexp' => [ + 'redirectRegex' => [ + 'regex' => '(.*)', + 'replacement' => $redirect_url, + 'permanent' => false, + ], + ], + ]; + } elseif ($usesErrorPage) { + // The noop service (no servers) keeps the 503; the errors middleware only replaces the body. + $dynamic_conf['http']['routers']['catchall']['middlewares'] = [ + 0 => 'error-pages', + ]; + $dynamic_conf['http']['middlewares'] = [ + 'error-pages' => [ + 'errors' => [ + 'status' => ['503'], + 'service' => 'error-pages', + 'query' => '/503.html', + ], + ], + ]; + $dynamic_conf['http']['services']['error-pages'] = [ + 'loadBalancer' => [ + 'servers' => [ + ['url' => 'http://'.self::PROXY_ERROR_PAGE_CONTAINER.':80'], + ], + ], + ]; + } + + // A router with `tls` matches only HTTPS, so plain HTTP needs its own catch-all router. + if (! $this->isSwarm()) { + $catchall = $dynamic_conf['http']['routers']['catchall']; + $dynamic_conf['http']['routers']['catchall']['entryPoints'] = ['https']; + unset($catchall['tls']); + $dynamic_conf['http']['routers']['catchall-http'] = array_merge($catchall, ['entryPoints' => ['http']]); + } + + return Yaml::dump($dynamic_conf, 12, 2); + } + + /** + * True when the catch-all shows the error page: a standalone Traefik or Caddy proxy with + * the default redirect enabled and no redirect URL. Swarm servers keep the plain 503. + */ + public function usesProxyErrorPage(): bool + { + return ! $this->isSwarm() + && in_array($this->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true) + && ($this->proxy->redirect_enabled ?? true) !== false + && blank($this->proxy->redirect_url); + } + + public function proxyErrorPagesPath(): string + { + return base_configuration_dir().'/proxy/error-pages'; + } + + /** + * The custom error page HTML, or the Coolify default page. + */ + public function proxyErrorPageHtml(): string + { + return filled($this->proxy_error_page) ? $this->proxy_error_page : view('proxy.error-page')->render(); + } + + /** + * Applications and services on this server that are in maintenance mode. + * + * @return Collection + */ + public function maintenanceResources(): Collection + { + if ($this->isSwarm() || ! in_array($this->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true)) { + return collect(); + } + + return $this->applications()->where('is_maintenance_enabled', true)->values() + ->concat($this->services()->where('is_maintenance_enabled', true)->get()); + } + + /** + * Writes 503.html and starts the error page container, or removes the container when + * neither the catch-all nor a maintenance page uses it. Swarm servers are not supported. + */ + public function setupProxyErrorPageContainer(): void + { + if ($this->isSwarm()) { + return; + } + + $container = self::PROXY_ERROR_PAGE_CONTAINER; + if (! $this->usesProxyErrorPage() && $this->maintenanceResources()->isEmpty()) { + instant_remote_process(["docker rm -f $container >/dev/null 2>&1 || true"], $this); + + return; + } + + $path = $this->proxyErrorPagesPath(); + $mount = devHostDockerPath($this, $path); + $restart = RESTART_MODE; + + instant_remote_process(["mkdir -p $path"], $this); + $this->uploadProxyFile("$path/503.html", $this->proxyErrorPageHtml()); + instant_remote_process([ + dockerNetworkEnsureCommand('coolify'), + "docker start $container >/dev/null 2>&1 || docker run -d --name $container --network coolify --restart $restart --label coolify.managed=true -v $mount:/www:ro ".self::PROXY_ERROR_PAGE_IMAGE.' httpd -f -p 80 -h /www', + // A proxy on custom networks only must still reach the error page container. + 'docker network connect coolify coolify-proxy >/dev/null 2>&1 || true', + ], $this); + } + + /** + * Writes $contents to $path on this server with scp. A shell command is limited to 128 KiB, + * so large pages and proxy files cannot go inside one command. + */ + public function uploadProxyFile(string $path, string $contents): void + { + $uploadPath = '/tmp/coolify-upload-'.bin2hex(random_bytes(8)); + $localPath = tempnam(sys_get_temp_dir(), 'coolify-proxy-file-'); + try { + file_put_contents($localPath, $contents); + instant_scp($localPath, $uploadPath, $this); + } finally { + @unlink($localPath); + } + + // The proxy and the page container read the file as root, other users do not need it. + instant_remote_process(["mv -f $uploadPath $path", "chmod 644 $path"], $this); + } + + /** + * Writes the maintenance pages and the proxy configuration that shows them instead of the + * applications and services in maintenance mode. Swarm servers are not supported. + */ + public function setupMaintenancePages(): void + { + $proxyType = $this->proxyType(); + if ($this->isSwarm() || ! in_array($proxyType, [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true)) { + return; + } + + $resources = $this->maintenanceResources(); + $path = $this->proxyErrorPagesPath(); + $keep = $resources->map(fn ($resource) => "! -name '{$resource->maintenancePageFile()}' ")->implode(''); + + instant_remote_process([ + "mkdir -p $path", + "find $path -maxdepth 1 -name 'maintenance-*.html' {$keep}-delete", + ], $this); + $this->setupProxyErrorPageContainer(); + + foreach ($resources as $resource) { + $this->uploadProxyFile("$path/{$resource->maintenancePageFile()}", $resource->maintenancePageHtml()); + } + + $dynamicPath = $this->proxyPath().'/dynamic'; + instant_remote_process(["mkdir -p $dynamicPath"], $this); + if ($proxyType === ProxyTypes::TRAEFIK->value) { + $file = "$dynamicPath/coolify-maintenance.yaml"; + $configuration = $this->maintenanceTraefikConfiguration($resources); + if ($configuration === null) { + instant_remote_process(["rm -f $file"], $this); + } else { + $this->uploadProxyFile($file, $configuration); + } + } else { + // caddy-docker-proxy reads the base Caddyfile again on its next poll. + $this->uploadProxyFile("$dynamicPath/Caddyfile", $this->caddyBaseCaddyfile($resources)); + } + } + + /** + * Traefik routers that use the same rule as the resource routers, with a priority one + * higher. Traefik uses the rule length as the default priority, so a resource on a longer + * path of the same host keeps its own router. + * + * @param Collection $resources + */ + public function maintenanceTraefikConfiguration(Collection $resources): ?string + { + $routers = []; + $middlewares = []; + foreach ($resources as $resource) { + $routes = $resource->maintenanceRoutes(); + if ($routes->isEmpty()) { + continue; + } + $middleware = "coolify-maintenance-{$resource->uuid}"; + $middlewares[$middleware] = [ + 'errors' => [ + 'status' => ['503'], + 'service' => 'coolify-maintenance-pages', + 'query' => '/'.$resource->maintenancePageFile(), + ], + ]; + foreach ($routes as $index => $route) { + $rule = "Host(`{$route['host']}`) && PathPrefix(`{$route['path']}`)"; + $router = [ + 'entryPoints' => ['http'], + 'rule' => $rule, + 'service' => 'coolify-maintenance-noop', + 'middlewares' => ['coolify-maintenance-headers', $middleware], + 'priority' => strlen($rule) + 1, + ]; + $routers["coolify-maintenance-{$resource->uuid}-{$index}-http"] = $router; + if ($route['scheme'] === 'https') { + $routers["coolify-maintenance-{$resource->uuid}-{$index}-https"] = array_merge($router, [ + 'entryPoints' => ['https'], + 'tls' => ['certResolver' => 'letsencrypt'], + ]); + } + } + } + + if ($routers === []) { + return null; + } + + $middlewares['coolify-maintenance-headers'] = [ + 'headers' => ['customResponseHeaders' => ['Retry-After' => '300']], + ]; + + return "# This file is generated by Coolify, do not edit it manually.\n\n".Yaml::dump([ + 'http' => [ + 'routers' => $routers, + 'middlewares' => $middlewares, + 'services' => [ + // No servers: Traefik answers 503 and the errors middleware adds the page. + 'coolify-maintenance-noop' => ['loadBalancer' => ['servers' => []]], + 'coolify-maintenance-pages' => [ + 'loadBalancer' => ['servers' => [['url' => 'http://'.self::PROXY_ERROR_PAGE_CONTAINER.':80']]], + ], + ], + ], + ], 12, 2); + } + + /** + * The base Caddyfile of caddy-docker-proxy. A maintenance block uses the same site address + * as the resource labels, so caddy-docker-proxy merges both into one site block. Its named + * matcher has the same path as the resource handle, so Caddy runs it first. + * + * @param Collection|null $resources + */ + public function caddyBaseCaddyfile(?Collection $resources = null): string + { + $resources ??= $this->maintenanceResources(); + $sites = []; + foreach ($resources as $resource) { + foreach ($resource->maintenanceRoutes() as $index => $route) { + $address = caddySiteAddress($route['scheme'], $route['host'], $route['force_https']); + $matcher = "@coolify-maintenance-{$resource->uuid}-{$index}"; + $sites[$address][] = implode("\n", [ + "\t{$matcher} path {$route['path']}*", + "\thandle {$matcher} {", + "\t\treverse_proxy ".self::PROXY_ERROR_PAGE_CONTAINER.':80 {', + "\t\t\trewrite /{$resource->maintenancePageFile()}", + "\t\t\theader_down Retry-After 300", + "\t\t\thandle_response {", + "\t\t\t\tcopy_response 503", + "\t\t\t}", + "\t\t}", + "\t}", + ]); + } + } + + $caddyfile = 'import /dynamic/*.caddy'; + foreach ($sites as $address => $blocks) { + $caddyfile .= "\n\n{$address} {\n".implode("\n", $blocks)."\n}"; + } + + return $caddyfile."\n"; + } + public function setupDynamicProxyConfiguration() { $settings = instanceSettings(); @@ -1272,6 +1601,12 @@ $siteAddress { return $this->caddyRunsCurrentVersion(); } + public function caddySupportsArgon2idBasicAuth(): bool + { + return $this->caddyRunsCurrentVersion() + && self::caddyDockerProxyImageVersion($this->configuredCaddyProxyImage()) >= [2, 11]; + } + public function isServerApiEnabled(): bool { return $this->settings->is_sentinel_enabled; diff --git a/app/Models/Service.php b/app/Models/Service.php index bcf9cf06ee..88df0ba058 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -9,6 +9,7 @@ use App\Support\ResourceStartActivity; use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasComposeVolumeWarnings; +use App\Traits\HasMaintenancePage; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -44,7 +45,7 @@ use OpenApi\Attributes as OA; )] class Service extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasMaintenancePage, HasSafeStringAttribute, HasSecretManager, SoftDeletes; private static $parserVersion = '5'; @@ -78,6 +79,13 @@ class Service extends BaseModel 'docker_compose_raw', ]; + protected function casts(): array + { + return [ + 'is_maintenance_enabled' => 'boolean', + ]; + } + protected static function booted() { static::creating(function ($service) { @@ -1515,6 +1523,29 @@ class Service extends BaseModel return $this->hasMany(ServiceApplication::class); } + /** + * Domains of every application in the service stack. + * + * @return Collection + */ + public function maintenanceDomains(): Collection + { + return $this->applications()->get() + ->flatMap(fn (ServiceApplication $application) => collect($application->fqdns) + ->map(fn ($url) => trim((string) $url)) + ->filter() + ->map(fn (string $url) => ['url' => $url, 'force_https' => $application->isForceHttpsEnabled()])) + ->values(); + } + + /** + * @return Collection + */ + public function maintenanceServers(): Collection + { + return collect([$this->server])->filter()->values(); + } + public function databases() { return $this->hasMany(ServiceDatabase::class); diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index b974e03db3..6a38840c5f 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -208,6 +208,10 @@ class ApplicationConfigurationSnapshot $this->item('is_http_basic_auth_enabled', 'HTTP basic auth', $this->application->is_http_basic_auth_enabled, 'redeploy'), $this->item('http_basic_auth_username', 'HTTP basic auth username', $this->application->http_basic_auth_username, 'redeploy'), $this->item('http_basic_auth_password', 'HTTP basic auth password', $this->application->http_basic_auth_password, 'redeploy', sensitive: true), + $this->item('http_basic_auth_hash_algorithm', 'HTTP basic auth hash algorithm', $this->application->http_basic_auth_hash_algorithm?->value, 'redeploy'), + $this->item('http_basic_auth_bcrypt_cost', 'HTTP basic auth bcrypt cost factor', $this->application->http_basic_auth_bcrypt_cost, 'redeploy'), + $this->item('http_basic_auth_argon2id_memory_cost', 'HTTP basic auth Argon2id memory (KiB)', $this->application->http_basic_auth_argon2id_memory_cost, 'redeploy'), + $this->item('http_basic_auth_argon2id_time_cost', 'HTTP basic auth Argon2id iterations', $this->application->http_basic_auth_argon2id_time_cost, 'redeploy'), ]; } diff --git a/app/Services/HostingerService.php b/app/Services/HostingerService.php new file mode 100644 index 0000000000..d90f28ef27 --- /dev/null +++ b/app/Services/HostingerService.php @@ -0,0 +1,224 @@ +token) + ->acceptJson() + ->asJson() + ->timeout(30) + ->connectTimeout(10) + ->retry( + $method === 'get' ? 3 : 1, + fn (int $attempt) => $attempt * 100, + fn (\Exception $exception) => ! $exception instanceof RequestException || $exception->response->serverError(), + throw: false + ) + ->{$method}($this->baseUrl.$endpoint, $data); + + if (! $response->successful()) { + if ($response->status() === 429) { + throw new RateLimitException( + 'Rate limit exceeded. Please try again later.', + $response->header('Retry-After') !== null ? (int) $response->header('Retry-After') : null + ); + } + + $message = $response->json('message') + ?? $response->json('error') + ?? 'Unknown error'; + + throw new \Exception('Hostinger API error: '.$message, $response->status()); + } + + return $response->json() ?? []; + } + + public function getDataCenters(): array + { + return $this->request('get', '/api/vps/v1/data-centers'); + } + + public function getTemplates(): array + { + return $this->request('get', '/api/vps/v1/templates'); + } + + /** + * Get the KVM VPS plans. Game Panel plans share the VPS category but are not offered for Coolify servers. + */ + public function getCatalogItems(): array + { + return collect($this->request('get', '/api/billing/v1/catalog', ['category' => 'VPS'])) + ->filter(fn ($item) => is_array($item) && preg_match('/-vps-kvm\d+$/', (string) ($item['id'] ?? '')) === 1) + ->values() + ->all(); + } + + public function getPublicKeys(): array + { + return $this->request('get', '/api/vps/v1/public-keys')['data'] ?? []; + } + + public function getPostInstallScripts(): array + { + return $this->request('get', '/api/vps/v1/post-install-scripts')['data'] ?? []; + } + + public function attachPublicKeys(int $virtualMachineId, array $publicKeyIds): array + { + return $this->request('post', "/api/vps/v1/public-keys/attach/{$virtualMachineId}", [ + 'ids' => array_map('intval', array_values($publicKeyIds)), + ]); + } + + public function purchaseVirtualMachine(array $params): array + { + // Hostinger accepts only a fully qualified hostname and uses its own default otherwise. + if (! str_contains($params['setup']['hostname'] ?? '', '.')) { + unset($params['setup']['hostname']); + } + + $existingVirtualMachineIds = collect($this->getVirtualMachines())->pluck('id')->all(); + + try { + $response = $this->request('post', '/api/vps/v1/virtual-machines', $params); + } catch (\Throwable $e) { + // Hostinger can charge for the VPS before its setup fails, so keep the VPS if one was created. + $virtualMachine = $this->findPurchasedVirtualMachine($existingVirtualMachineIds); + if (! $virtualMachine) { + throw $e; + } + + logger()->warning('Hostinger charged for a VPS but its setup failed', [ + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'error' => $e->getMessage(), + ]); + + return $this->setupPurchasedVirtualMachine($virtualMachine, $params['setup']); + } + + if (empty($response['virtual_machine']['id'])) { + logger()->warning('Hostinger VPS order did not return a virtual machine', Arr::only($response, ['id', 'subscription_id', 'status', 'message'])); + + throw new \Exception('Hostinger order '.($response['id'] ?? 'unknown').' is waiting for payment. Finish the VPS setup in hPanel.', 202); + } + + return $response['virtual_machine']; + } + + private function findPurchasedVirtualMachine(array $existingVirtualMachineIds): ?array + { + try { + return collect($this->getVirtualMachines()) + ->first(fn (array $virtualMachine) => ! in_array($virtualMachine['id'] ?? null, $existingVirtualMachineIds, true)); + } catch (\Throwable) { + return null; + } + } + + private function setupPurchasedVirtualMachine(array $virtualMachine, array $setup): array + { + if (($virtualMachine['state'] ?? null) !== 'initial') { + return $virtualMachine; + } + + try { + return $this->request('post', $this->virtualMachineEndpoint((int) $virtualMachine['id']).'/setup', $setup); + } catch (\Throwable $e) { + logger()->warning('Hostinger VPS setup retry failed', [ + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'error' => $e->getMessage(), + ]); + + return $virtualMachine; + } + } + + public function getVirtualMachine(int $virtualMachineId): array + { + return $this->request('get', $this->virtualMachineEndpoint($virtualMachineId)); + } + + public function getVirtualMachines(): array + { + return $this->request('get', '/api/vps/v1/virtual-machines'); + } + + /** + * Briefly wait for a public IP. VPS setup usually takes longer, so the server status check fills in the IP later. + */ + public function waitForPublicIp(array $virtualMachine, int $attempts = 10, int $sleepMilliseconds = 1000): array + { + if ($this->getPublicIpAddress($virtualMachine) || empty($virtualMachine['id'])) { + return $virtualMachine; + } + + for ($attempt = 0; $attempt < $attempts; $attempt++) { + usleep($sleepMilliseconds * 1000); + + $virtualMachine = $this->getVirtualMachine((int) $virtualMachine['id']); + + if ($this->getPublicIpAddress($virtualMachine)) { + return $virtualMachine; + } + } + + return $virtualMachine; + } + + public function getPublicIpAddress(array $virtualMachine): ?string + { + foreach (['ipv4', 'ipv6'] as $version) { + foreach ($virtualMachine[$version] ?? [] as $ipAddress) { + if (filled($ipAddress['address'] ?? null)) { + return $ipAddress['address']; + } + } + } + + return null; + } + + public function disableAutoRenewal(string $subscriptionId): array + { + return $this->request('delete', "/api/billing/v1/subscriptions/{$subscriptionId}/auto-renewal/disable"); + } + + public function startVirtualMachine(int $virtualMachineId): array + { + return $this->request('post', $this->virtualMachineEndpoint($virtualMachineId).'/start'); + } + + public function findVirtualMachineByIp(string $ip): ?array + { + foreach ($this->getVirtualMachines() as $virtualMachine) { + foreach (['ipv4', 'ipv6'] as $version) { + foreach ($virtualMachine[$version] ?? [] as $ipAddress) { + if (($ipAddress['address'] ?? null) === $ip) { + return $virtualMachine; + } + } + } + } + + return null; + } + + private function virtualMachineEndpoint(int $virtualMachineId): string + { + return '/api/vps/v1/virtual-machines/'.$virtualMachineId; + } +} diff --git a/app/Traits/HasMaintenancePage.php b/app/Traits/HasMaintenancePage.php new file mode 100644 index 0000000000..ed5b9d3827 --- /dev/null +++ b/app/Traits/HasMaintenancePage.php @@ -0,0 +1,85 @@ + + */ + abstract public function maintenanceDomains(): Collection; + + /** + * @return Collection + */ + abstract public function maintenanceServers(): Collection; + + /** + * Proxy routes for the maintenance page. Domains go into the Traefik and Caddy configuration + * as plain text, so a domain with an unexpected host or path is skipped. + * + * @return Collection + */ + public function maintenanceRoutes(): Collection + { + return $this->maintenanceDomains() + ->map(function (array $domain): ?array { + try { + $url = Url::fromString(trim($domain['url'])); + } catch (\Throwable) { + return null; + } + // Host and scheme stay as the proxy labels use them, so the Caddy site address is the same. + $scheme = $url->getScheme(); + $host = $url->getHost(); + $path = $url->getPath() ?: '/'; + + if (! in_array($scheme, ['http', 'https'], true) + || ! preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/i', $host) + || ! preg_match('#^/[A-Za-z0-9/._~%-]*$#', $path)) { + return null; + } + + return ['scheme' => $scheme, 'host' => $host, 'path' => $path, 'force_https' => $domain['force_https']]; + }) + ->filter() + ->unique(fn (array $route) => "{$route['scheme']}://{$route['host']}{$route['path']}") + ->values(); + } + + /** + * The custom maintenance page HTML, or the Coolify default page. + */ + public function maintenancePageHtml(): string + { + return filled($this->maintenance_page) ? $this->maintenance_page : view('proxy.maintenance-page')->render(); + } + + public function maintenancePageFile(): string + { + return "maintenance-{$this->uuid}.html"; + } + + /** + * Applies the maintenance state of this resource on every server that runs it. + */ + public function syncMaintenancePage(?Collection $servers = null): void + { + foreach ($servers ?? $this->maintenanceServers() as $server) { + if ($server->isFunctional()) { + $server->setupMaintenancePages(); + } + } + } +} diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index ee89f52c6f..46501331e9 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -2,6 +2,7 @@ use App\Actions\Shared\MigrateResourceToDestination; use App\Enums\BuildPackTypes; +use App\Enums\HttpBasicAuthHashAlgorithm; use App\Enums\RedirectTypes; use App\Enums\StaticImageTypes; use App\Models\Application; @@ -276,6 +277,10 @@ function sharedDataApplications(?Application $application = null): array 'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(), 'is_container_label_escape_enabled' => 'boolean', 'is_preserve_repository_enabled' => 'boolean', + 'http_basic_auth_hash_algorithm' => Rule::enum(HttpBasicAuthHashAlgorithm::class), + 'http_basic_auth_bcrypt_cost' => 'integer|min:4|max:14', + 'http_basic_auth_argon2id_memory_cost' => 'integer|min:8192|max:262144', + 'http_basic_auth_argon2id_time_cost' => 'integer|min:1|max:12', ]; } diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 8cba703241..2c1025b623 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -766,7 +766,20 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool ->contains(ValidationPatterns::normalizeApplicationDomainUrl($domain)); } -function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false) +/** + * Caddy site address of a domain. Maintenance blocks in the base Caddyfile must use the same + * address as the labels, so caddy-docker-proxy merges them into one site block. + */ +function caddySiteAddress(string $scheme, string $host, bool $isForceHttpsEnabled): string +{ + if ($scheme === 'https' && ! $isForceHttpsEnabled) { + return "http://{$host}, https://{$host}"; + } + + return "{$scheme}://{$host}"; +} + +function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false, ?int $http_basic_auth_bcrypt_cost = null, array $http_basic_auth_argon2id_options = []) { $labels = collect([]); if ($serviceLabels) { @@ -777,7 +790,9 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, $is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null; if ($is_http_basic_auth_enabled) { - $hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => 10]); + $hashedPassword = $http_basic_auth_argon2id_options + ? password_hash($http_basic_auth_password, PASSWORD_ARGON2ID, $http_basic_auth_argon2id_options) + : password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => $http_basic_auth_bcrypt_cost ?? 10]); } $trafficAppKey = caddyTrafficAppKey($uuid, $service_name); @@ -788,10 +803,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, $path = $url->getPath(); $host_without_www = str($host)->replace('www.', ''); $schema = $url->getScheme(); - $siteAddress = "{$schema}://{$host}"; - if ($schema === 'https' && ! $is_force_https_enabled) { - $siteAddress = "http://{$host}, https://{$host}"; - } + $siteAddress = caddySiteAddress($schema, $host, $is_force_https_enabled); $portlessDomain = ServiceApplication::withoutPort($domain); $port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null); $handle = 'handle_path'; @@ -834,6 +846,9 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, if ($is_http_basic_auth_enabled) { // Caddy 2.8 renamed basicauth to basic_auth; see Server::caddySupportsBasicAuthDirective(). $basicAuthDirective = $supports_basic_auth_directive ? 'basic_auth' : 'basicauth'; + if ($http_basic_auth_argon2id_options) { + $labels->push("caddy_{$loop}.{$basicAuthDirective}=argon2id"); + } $labels->push("caddy_{$loop}.{$basicAuthDirective}.{$http_basic_auth_username}=\"{$hashedPassword}\""); } if ($is_traffic_analytics_enabled) { @@ -907,7 +922,7 @@ function dockerComposeServicePorts(?string $compose, ?string $serviceName): arra ->unique()->values()->all(); } -function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = []) +function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [], ?int $http_basic_auth_bcrypt_cost = null) { $labels = collect([]); $labels->push('traefik.enable=true'); @@ -919,7 +934,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null; $http_basic_auth_label = "http-basic-auth-{$uuid}"; if ($is_http_basic_auth_enabled) { - $hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => 10]); + $hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => $http_basic_auth_bcrypt_cost ?? 10]); } if ($is_http_basic_auth_enabled) { @@ -1186,6 +1201,10 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview $appUuid = $appUuid.'-pr-'.$pull_request_id; } $labels = collect([]); + $httpBasicAuthArgon2idOptions = $application->usesArgon2idBasicAuth() ? [ + 'memory_cost' => $application->http_basic_auth_argon2id_memory_cost, + 'time_cost' => $application->http_basic_auth_argon2id_time_cost, + ] : []; if ($pull_request_id === 0) { if ($application->fqdn) { $domains = str(data_get($application, 'fqdn'))->explode(','); @@ -1205,6 +1224,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, noindex_domains: $noindexDomains, domainPortOverrides: $application->domain_port_overrides ?? [], )); @@ -1222,6 +1242,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, + http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), @@ -1242,6 +1264,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $application->domain_port_overrides ?? [], @@ -1258,6 +1281,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, + http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), @@ -1288,6 +1313,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $preview->domain_port_overrides ?? [], @@ -1305,6 +1331,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, + http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), @@ -1324,6 +1352,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $preview->domain_port_overrides ?? [], @@ -1339,6 +1368,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, + http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), @@ -2078,3 +2109,37 @@ function injectDockerComposeBuildArgs(string $command, string $buildArgsString): return $modifiedCommand ?? $command; } + +/** + * Run a command that uses the Railpack BuildKit builder, then stop the builder when no other + * process uses it. An idle buildkitd keeps the memory of earlier builds until it restarts. + * + * The lock file is in the buildx metadata directory, which all helper containers of a server share. + * The script has no `&&`, `||`, pipe, or `$(`, because the sudo parser would put sudo into it. + */ +function railpackBuilderLockedScript(string $command): string +{ + return 'exec 9>/root/.docker/buildx/coolify-railpack.lock; if flock -s 9; then :; else exit $?; fi; ' + ."{$command}; status=\$?; " + .'if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack >/dev/null 2>&1; fi; ' + .'exit $status'; +} + +/** + * The buildx metadata of a server, as the `docker run -v` source that the deployment helper uses. + */ +function railpackBuildxMetadataVolume(Server $server): string +{ + return isDev() && $server->isLocalhost() ? 'coolify-buildx' : '$HOME/.docker/buildx'; +} + +/** + * Run a command for the Railpack builder from a temporary helper container, then stop the builder + * when no build uses it. + */ +function railpackBuilderHelperCommand(string $buildxMetadataVolume, string $helperImage, string $command): string +{ + $script = railpackBuilderLockedScript($command); + + return "docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImage} bash -c '{$script}' 2>/dev/null || true"; +} diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index a4b98ca5fc..613f925102 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -950,3 +950,16 @@ function getTraefikVersionFromDockerCompose(Server $server): ?string return null; } } + +/** + * Validation rule for an HTML page that Coolify sends to the proxy in one shell command + * (see Server::PROXY_ERROR_PAGE_MAX_BYTES). + */ +function proxyPageSizeRule(string $name): Closure +{ + return function (string $attribute, mixed $value, Closure $fail) use ($name): void { + if (strlen((string) $value) > Server::PROXY_ERROR_PAGE_MAX_BYTES) { + $fail("The {$name} must not be larger than ".(Server::PROXY_ERROR_PAGE_MAX_BYTES / 1024).' KB.'); + } + }; +} diff --git a/composer.json b/composer.json index d00dd4c476..7e92f92cb3 100644 --- a/composer.json +++ b/composer.json @@ -12,67 +12,68 @@ ], "require": { "php": "^8.4", - "danharrin/livewire-rate-limiting": "2.2.1", - "doctrine/dbal": "4.4.4", - "firebase/php-jwt": "7.1.0", - "guzzlehttp/guzzle": "7.15.3", - "laravel/fortify": "1.37.3", - "laravel/framework": "12.69.3", - "laravel/horizon": "5.48.2", - "laravel/mcp": "1.0.1", - "laravel/pail": "1.2.7", - "laravel/prompts": "0.3.22", - "laravel/reverb": "1.11.1", - "laravel/sanctum": "4.3.3", - "laravel/socialite": "5.29.0", - "laravel/tinker": "2.11.1", - "laravel/ui": "4.6.3", - "lcobucci/jwt": "5.6.0", - "league/flysystem-aws-s3-v3": "3.35.2", - "league/flysystem-sftp-v3": "3.33.0", - "livewire/livewire": "3.8.7", - "log1x/laravel-webfonts": "2.0.1", - "lorisleiva/laravel-actions": "2.10.2", - "nubs/random-name-generator": "2.2.0", - "phpseclib/phpseclib": "3.0.57", - "pion/laravel-chunk-upload": "1.6.3", - "poliander/cron": "3.3.1", - "purplepixie/phpdns": "2.3.6", - "pusher/pusher-php-server": "7.3.0", - "resend/resend-laravel": "1.5.0", - "sentry/sentry-laravel": "4.27.0", - "socialiteproviders/authentik": "5.3.0", - "socialiteproviders/clerk": "5.1.0", - "socialiteproviders/discord": "4.2.0", - "socialiteproviders/google": "4.1.0", - "socialiteproviders/infomaniak": "4.0.0", - "socialiteproviders/microsoft-azure": "5.2.0", - "socialiteproviders/zitadel": "4.2.0", - "spatie/laravel-activitylog": "4.12.3", - "spatie/laravel-data": "4.23.0", - "spatie/laravel-markdown": "2.8.0", - "spatie/laravel-schemaless-attributes": "2.6.0", - "spatie/url": "2.4.0", - "stevebauman/purify": "6.3.2", - "stripe/stripe-php": "16.6.0", - "symfony/yaml": "7.4.15", - "visus/cuid2": "6.0.0", - "yosymfony/toml": "1.0.4", - "zircote/swagger-php": "5.8.3" + "danharrin/livewire-rate-limiting": "^2.3.0", + "doctrine/dbal": "^4.5.0", + "firebase/php-jwt": "^7.2.1", + "guzzlehttp/guzzle": "^7.15.5", + "laravel/fortify": "^1.40.0", + "laravel/framework": "^12.69.3", + "laravel/horizon": "^5.50.0", + "laravel/mcp": "^1.0.1", + "laravel/pail": "^1.2.7", + "laravel/prompts": "^0.3.24", + "laravel/reverb": "^1.12.0", + "laravel/sanctum": "^4.3.3", + "laravel/socialite": "^5.30.0", + "laravel/tinker": "^3.0.2", + "laravel/ui": "^4.6.3", + "lcobucci/jwt": "^5.6.0", + "league/flysystem-aws-s3-v3": "^3.35.3", + "league/flysystem-sftp-v3": "^3.36.0", + "livewire/livewire": "^3.8.10", + "log1x/laravel-webfonts": "^2.0.1", + "lorisleiva/laravel-actions": "^2.12.0", + "nubs/random-name-generator": "^2.2.0", + "phpseclib/phpseclib": "^3.0.57", + "pion/laravel-chunk-upload": "^1.6.3", + "poliander/cron": "^3.3.1", + "purplepixie/phpdns": "^2.4.0", + "pusher/pusher-php-server": "^7.3.0", + "resend/resend-laravel": "^1.6.0", + "sentry/sentry-laravel": "^4.28.0", + "socialiteproviders/authentik": "^5.3.0", + "socialiteproviders/clerk": "^5.1.0", + "socialiteproviders/discord": "^4.2.0", + "socialiteproviders/google": "^4.1.0", + "socialiteproviders/infomaniak": "^4.0.0", + "socialiteproviders/microsoft-azure": "^5.2.1", + "socialiteproviders/zitadel": "^4.2.0", + "spatie/laravel-activitylog": "^4.12.3", + "spatie/laravel-data": "^4.23.0", + "spatie/laravel-markdown": "^2.8.0", + "spatie/laravel-schemaless-attributes": "^2.6.0", + "spatie/url": "^2.4.0", + "stevebauman/purify": "^6.3.2", + "stripe/stripe-php": "^16.6.0", + "symfony/yaml": "^8.1.8", + "visus/cuid2": "^7.0.0", + "yosymfony/toml": "^1.0.4", + "zircote/swagger-php": "^6.11.0" }, "require-dev": { - "driftingly/rector-laravel": "2.5.0", - "fakerphp/faker": "1.24.1", - "laravel/boost": "2.10.0", - "laravel/pint": "1.30.4", - "mockery/mockery": "1.6.12", - "nunomaduro/collision": "8.9.5", - "pestphp/pest": "4.7.8", - "pestphp/pest-plugin-browser": "4.3.1", - "phpstan/phpstan": "2.2.8", - "rector/rector": "2.6.1", - "serversideup/spin": "3.3.0", - "symfony/http-client": "7.4.16" + "driftingly/rector-laravel": "^2.6.2", + "fakerphp/faker": "^1.24.1", + "laravel/boost": "^2.10.0", + "laravel/pint": "^1.32.1", + "laravel/vet": "^0.2.1", + "mockery/mockery": "^1.6.15", + "nunomaduro/collision": "^8.9.5", + "pestphp/pest": "^4.7.8", + "pestphp/pest-plugin-browser": "^4.3.1", + "phpstan/phpstan": "^2.2.16", + "rector/rector": "^2.6.7", + "serversideup/spin": "^3.5.2", + "symfony/http-client": "^8.1.8" }, "minimum-stability": "stable", "prefer-stable": true, @@ -94,7 +95,8 @@ "config": { "allow-plugins": { "pestphp/pest-plugin": true, - "php-http/discovery": true + "php-http/discovery": true, + "laravel/vet": true }, "optimize-autoloader": true, "preferred-install": "dist", diff --git a/composer.lock b/composer.lock index 37c9d9e856..43835e7622 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e67d127267c946e452e424751c69ebbd", + "content-hash": "664589c32e86e8e6fc2a0fb17feab410", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.391.1", + "version": "3.398.4", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "90920ce5518aa864e04da0224bb0ba0ee9e31d7f" + "reference": "1dc5a7813aa23b65de0bfb6239b93387971f7143" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/90920ce5518aa864e04da0224bb0ba0ee9e31d7f", - "reference": "90920ce5518aa864e04da0224bb0ba0ee9e31d7f", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/1dc5a7813aa23b65de0bfb6239b93387971f7143", + "reference": "1dc5a7813aa23b65de0bfb6239b93387971f7143", "shasum": "" }, "require": { @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.391.1" + "source": "https://github.com/aws/aws-sdk-php/tree/3.398.4" }, - "time": "2026-08-07T20:18:18+00:00" + "time": "2026-09-30T18:20:49+00:00" }, { "name": "bacon/bacon-qr-code", @@ -274,16 +274,16 @@ }, { "name": "carbonphp/carbon-doctrine-types", - "version": "3.2.0", + "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", - "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", - "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", "shasum": "" }, "require": { @@ -298,6 +298,11 @@ "phpunit/phpunit": "^10.3" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, "autoload": { "psr-4": { "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" @@ -323,7 +328,7 @@ ], "support": { "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", - "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.1" }, "funding": [ { @@ -339,7 +344,7 @@ "type": "tidelift" } ], - "time": "2024-02-09T16:56:22+00:00" + "time": "2026-09-06T14:11:38+00:00" }, { "name": "clue/redis-protocol", @@ -473,16 +478,16 @@ }, { "name": "danharrin/livewire-rate-limiting", - "version": "v2.2.1", + "version": "v2.3.0", "source": { "type": "git", "url": "https://github.com/danharrin/livewire-rate-limiting.git", - "reference": "69436717dc70e30f80d7f8fd02504c22992a9ad5" + "reference": "46c4ceaf7997c713c79afe7c507a39649f6c9d6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/danharrin/livewire-rate-limiting/zipball/69436717dc70e30f80d7f8fd02504c22992a9ad5", - "reference": "69436717dc70e30f80d7f8fd02504c22992a9ad5", + "url": "https://api.github.com/repos/danharrin/livewire-rate-limiting/zipball/46c4ceaf7997c713c79afe7c507a39649f6c9d6f", + "reference": "46c4ceaf7997c713c79afe7c507a39649f6c9d6f", "shasum": "" }, "require": { @@ -523,7 +528,7 @@ "type": "github" } ], - "time": "2026-08-06T08:41:51+00:00" + "time": "2026-09-18T07:38:01+00:00" }, { "name": "dasprid/enum", @@ -652,16 +657,16 @@ }, { "name": "doctrine/dbal", - "version": "4.4.4", + "version": "4.5.0", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce" + "reference": "c435cd7ea33e17d2a89288caf994cf3d4549b879" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce", - "reference": "fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/c435cd7ea33e17d2a89288caf994cf3d4549b879", + "reference": "c435cd7ea33e17d2a89288caf994cf3d4549b879", "shasum": "" }, "require": { @@ -677,9 +682,9 @@ "phpstan/phpstan": "2.1.30", "phpstan/phpstan-phpunit": "2.0.7", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "11.5.50", - "slevomat/coding-standard": "8.27.1", - "squizlabs/php_codesniffer": "4.0.1", + "phpunit/phpunit": "11.5.56", + "slevomat/coding-standard": "8.31.1", + "squizlabs/php_codesniffer": "4.0.4", "symfony/cache": "^6.3.8|^7.0|^8.0", "symfony/console": "^5.4|^6.3|^7.0|^8.0" }, @@ -738,7 +743,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.4.4" + "source": "https://github.com/doctrine/dbal/tree/4.5.0" }, "funding": [ { @@ -754,7 +759,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T14:34:40+00:00" + "time": "2026-09-24T16:11:26+00:00" }, { "name": "doctrine/deprecations", @@ -1149,16 +1154,16 @@ }, { "name": "ezyang/htmlpurifier", - "version": "v4.19.0", + "version": "v4.19.1", "source": { "type": "git", "url": "https://github.com/ezyang/htmlpurifier.git", - "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + "reference": "5e0539132d934f936fbae2c88b55d58f9438623f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", - "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/5e0539132d934f936fbae2c88b55d58f9438623f", + "reference": "5e0539132d934f936fbae2c88b55d58f9438623f", "shasum": "" }, "require": { @@ -1204,32 +1209,32 @@ ], "support": { "issues": "https://github.com/ezyang/htmlpurifier/issues", - "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.1" }, - "time": "2025-10-17T16:34:55+00:00" + "time": "2026-09-15T15:42:51+00:00" }, { "name": "firebase/php-jwt", - "version": "v7.1.0", + "version": "v7.2.1", "source": { "type": "git", "url": "https://github.com/googleapis/php-jwt.git", - "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0" + "reference": "0501f5729b9e2448516674aff07c7d1727869b8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0", - "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/0501f5729b9e2448516674aff07c7d1727869b8e", + "reference": "0501f5729b9e2448516674aff07c7d1727869b8e", "shasum": "" }, "require": { "php": "^8.0" }, "require-dev": { - "guzzlehttp/guzzle": "^7.4", + "guzzlehttp/guzzle": "^7.4||^8.0", "phpfastcache/phpfastcache": "^9.2", "phpseclib/phpseclib": "~3.0", - "phpspec/prophecy-phpunit": "^2.0", + "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^9.5", "psr/cache": "^2.0||^3.0", "psr/http-client": "^1.0", @@ -1241,6 +1246,14 @@ "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" }, "type": "library", + "extra": { + "component": { + "id": "jwt", + "path": "Jwt", + "entry": "README.md", + "target": "googleapis/php-jwt.git" + } + }, "autoload": { "psr-4": { "Firebase\\JWT\\": "src" @@ -1270,9 +1283,9 @@ ], "support": { "issues": "https://github.com/googleapis/php-jwt/issues", - "source": "https://github.com/googleapis/php-jwt/tree/v7.1.0" + "source": "https://github.com/googleapis/php-jwt/tree/v7.2.1" }, - "time": "2026-06-11T17:54:14+00:00" + "time": "2026-09-28T17:51:46+00:00" }, { "name": "fruitcake/php-cors", @@ -1347,24 +1360,24 @@ }, { "name": "graham-campbell/result-type", - "version": "v1.1.4", + "version": "v1.2.0", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5" + "phpoption/phpoption": "^1.10" }, "require-dev": { - "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" }, "type": "library", "autoload": { @@ -1393,7 +1406,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" }, "funding": [ { @@ -1405,26 +1418,26 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:43:20+00:00" + "time": "2026-08-24T09:06:52+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "7.15.3", + "version": "7.15.5", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc" + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", - "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5.2", - "guzzlehttp/psr7": "^2.13", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -1517,7 +1530,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.15.3" + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" }, "funding": [ { @@ -1533,20 +1546,20 @@ "type": "tidelift" } ], - "time": "2026-08-05T19:48:21+00:00" + "time": "2026-08-24T09:21:06+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.2", + "version": "2.5.3", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce" + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/2823687acff28b2dbe67b2508a6b300e2c3fa4ce", - "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", "shasum": "" }, "require": { @@ -1601,7 +1614,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.2" + "source": "https://github.com/guzzle/promises/tree/2.5.3" }, "funding": [ { @@ -1617,7 +1630,7 @@ "type": "tidelift" } ], - "time": "2026-08-05T19:30:54+00:00" + "time": "2026-08-24T09:11:28+00:00" }, { "name": "guzzlehttp/psr7", @@ -1740,16 +1753,16 @@ }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.10", + "version": "v1.0.11", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839" + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/f6c24c21f42b990e9a58912b332d0874df6ba839", - "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/d0058dccf4299d70c3d9da3378b8908b32780368", + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368", "shasum": "" }, "require": { @@ -1806,7 +1819,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.10" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.11" }, "funding": [ { @@ -1822,7 +1835,7 @@ "type": "tidelift" } ], - "time": "2026-07-17T13:53:03+00:00" + "time": "2026-08-24T09:15:32+00:00" }, { "name": "jean85/pretty-package-versions", @@ -1886,16 +1899,16 @@ }, { "name": "laravel/fortify", - "version": "v1.37.3", + "version": "v1.40.0", "source": { "type": "git", "url": "https://github.com/laravel/fortify.git", - "reference": "66b9503330e7c18b3edebb9c3b4087037826573b" + "reference": "fe0fce8814660317df0684f2c7be3b573def67d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/fortify/zipball/66b9503330e7c18b3edebb9c3b4087037826573b", - "reference": "66b9503330e7c18b3edebb9c3b4087037826573b", + "url": "https://api.github.com/repos/laravel/fortify/zipball/fe0fce8814660317df0684f2c7be3b573def67d3", + "reference": "fe0fce8814660317df0684f2c7be3b573def67d3", "shasum": "" }, "require": { @@ -1909,7 +1922,7 @@ }, "require-dev": { "orchestra/testbench": "^9.15|^10.8|^11.0", - "phpstan/phpstan": "^1.10" + "phpstan/phpstan": "^2.2.6" }, "type": "library", "extra": { @@ -1946,7 +1959,7 @@ "issues": "https://github.com/laravel/fortify/issues", "source": "https://github.com/laravel/fortify" }, - "time": "2026-06-29T16:22:02+00:00" + "time": "2026-09-10T11:52:08+00:00" }, { "name": "laravel/framework", @@ -2172,16 +2185,16 @@ }, { "name": "laravel/horizon", - "version": "v5.48.2", + "version": "v5.50.0", "source": { "type": "git", "url": "https://github.com/laravel/horizon.git", - "reference": "2ebe3cb25ab6461b53a4e3ef42e167edeafe7932" + "reference": "009177cc3d0829c839c8ab573dd17d158fc0b71e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/horizon/zipball/2ebe3cb25ab6461b53a4e3ef42e167edeafe7932", - "reference": "2ebe3cb25ab6461b53a4e3ef42e167edeafe7932", + "url": "https://api.github.com/repos/laravel/horizon/zipball/009177cc3d0829c839c8ab573dd17d158fc0b71e", + "reference": "009177cc3d0829c839c8ab573dd17d158fc0b71e", "shasum": "" }, "require": { @@ -2246,9 +2259,9 @@ ], "support": { "issues": "https://github.com/laravel/horizon/issues", - "source": "https://github.com/laravel/horizon/tree/v5.48.2" + "source": "https://github.com/laravel/horizon/tree/v5.50.0" }, - "time": "2026-07-27T13:13:49+00:00" + "time": "2026-09-15T13:59:54+00:00" }, { "name": "laravel/mcp", @@ -2474,16 +2487,16 @@ }, { "name": "laravel/prompts", - "version": "v0.3.22", + "version": "v0.3.24", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "02b89b39e8972a998db4d5d4ad4719239dd4aee4" + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/02b89b39e8972a998db4d5d4ad4719239dd4aee4", - "reference": "02b89b39e8972a998db4d5d4ad4719239dd4aee4", + "url": "https://api.github.com/repos/laravel/prompts/zipball/5d3cdef29e93ca3b62b1871359db3078cd99908b", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b", "shasum": "" }, "require": { @@ -2527,22 +2540,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.22" + "source": "https://github.com/laravel/prompts/tree/v0.3.24" }, - "time": "2026-08-04T14:50:50+00:00" + "time": "2026-08-20T12:55:36+00:00" }, { "name": "laravel/reverb", - "version": "v1.11.1", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/laravel/reverb.git", - "reference": "52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27" + "reference": "e233471f53641a447d3b7a2150446d47a0e8c043" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/reverb/zipball/52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27", - "reference": "52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27", + "url": "https://api.github.com/repos/laravel/reverb/zipball/e233471f53641a447d3b7a2150446d47a0e8c043", + "reference": "e233471f53641a447d3b7a2150446d47a0e8c043", "shasum": "" }, "require": { @@ -2562,6 +2575,7 @@ "symfony/http-foundation": "^6.3|^7.0|^8.0" }, "require-dev": { + "jasonmccreary/double": "~0.10", "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", "pestphp/pest": "^2.0|^3.0|^4.0", "phpstan/phpstan": "^1.10", @@ -2605,10 +2619,9 @@ "websocket" ], "support": { - "issues": "https://github.com/laravel/reverb/issues", - "source": "https://github.com/laravel/reverb/tree/v1.11.1" + "source": "https://github.com/laravel/reverb/tree/v1.12.0" }, - "time": "2026-08-06T14:48:58+00:00" + "time": "2026-09-22T14:25:47+00:00" }, { "name": "laravel/sanctum", @@ -2731,16 +2744,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.15", + "version": "v2.1.0", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661" + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/dccd8bcb851bb03fcc005df650b708b57cc52661", - "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5", "shasum": "" }, "require": { @@ -2788,20 +2801,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-07-21T16:49:22+00:00" + "time": "2026-09-22T14:32:34+00:00" }, { "name": "laravel/socialite", - "version": "v5.29.0", + "version": "v5.30.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "cd343a5841f02292af119ee607edc71300c9ae4f" + "reference": "caf714f55d51ab0d914b40033d8b0f489d6219cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/cd343a5841f02292af119ee607edc71300c9ae4f", - "reference": "cd343a5841f02292af119ee607edc71300c9ae4f", + "url": "https://api.github.com/repos/laravel/socialite/zipball/caf714f55d51ab0d914b40033d8b0f489d6219cc", + "reference": "caf714f55d51ab0d914b40033d8b0f489d6219cc", "shasum": "" }, "require": { @@ -2860,37 +2873,37 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-07-01T13:50:23+00:00" + "time": "2026-08-13T23:01:33+00:00" }, { "name": "laravel/tinker", - "version": "v2.11.1", + "version": "v3.0.2", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + "reference": "4faba77764bd33411735936acdf30446d058c78b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", - "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "url": "https://api.github.com/repos/laravel/tinker/zipball/4faba77764bd33411735936acdf30446d058c78b", + "reference": "4faba77764bd33411735936acdf30446d058c78b", "shasum": "" }, "require": { - "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "php": "^7.2.5|^8.0", - "psy/psysh": "^0.11.1|^0.12.0", - "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "psy/psysh": "^0.12.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "~1.3.3|^1.4.2", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + "phpunit/phpunit": "^10.5|^11.5" }, "suggest": { - "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + "illuminate/database": "The Illuminate Database package (^8.0|^9.0|^10.0|^11.0|^12.0|^13.0)." }, "type": "library", "extra": { @@ -2898,6 +2911,9 @@ "providers": [ "Laravel\\Tinker\\TinkerServiceProvider" ] + }, + "branch-alias": { + "dev-master": "3.x-dev" } }, "autoload": { @@ -2924,9 +2940,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.11.1" + "source": "https://github.com/laravel/tinker/tree/v3.0.2" }, - "time": "2026-02-06T14:12:35+00:00" + "time": "2026-03-17T14:54:13+00:00" }, { "name": "laravel/ui", @@ -3338,16 +3354,16 @@ }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.35.2", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4" + "reference": "b03780cb97585ee7e48977f40ed599b33b751634" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/8475ef9adfc6498b85469e2abec6fe3118cd08c4", - "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/b03780cb97585ee7e48977f40ed599b33b751634", + "reference": "b03780cb97585ee7e48977f40ed599b33b751634", "shasum": "" }, "require": { @@ -3387,9 +3403,9 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.2" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.3" }, - "time": "2026-07-01T23:25:49+00:00" + "time": "2026-08-08T16:19:23+00:00" }, { "name": "league/flysystem-local", @@ -3442,16 +3458,16 @@ }, { "name": "league/flysystem-sftp-v3", - "version": "3.33.0", + "version": "3.36.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-sftp-v3.git", - "reference": "34ff5ef0f841add92e2b902c1005f72135b03646" + "reference": "5004b451cedd3bc4fdf84fcd427b858ec1b66a02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-sftp-v3/zipball/34ff5ef0f841add92e2b902c1005f72135b03646", - "reference": "34ff5ef0f841add92e2b902c1005f72135b03646", + "url": "https://api.github.com/repos/thephpleague/flysystem-sftp-v3/zipball/5004b451cedd3bc4fdf84fcd427b858ec1b66a02", + "reference": "5004b451cedd3bc4fdf84fcd427b858ec1b66a02", "shasum": "" }, "require": { @@ -3485,9 +3501,9 @@ "sftp" ], "support": { - "source": "https://github.com/thephpleague/flysystem-sftp-v3/tree/3.33.0" + "source": "https://github.com/thephpleague/flysystem-sftp-v3/tree/3.36.0" }, - "time": "2026-03-20T13:22:31+00:00" + "time": "2026-09-02T07:49:56+00:00" }, { "name": "league/mime-type-detection", @@ -3547,23 +3563,23 @@ }, { "name": "league/oauth1-client", - "version": "v1.11.0", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/thephpleague/oauth1-client.git", - "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055" + "reference": "aa8fe766f772f9233d6c06f9ef003fd8129e408d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055", - "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/aa8fe766f772f9233d6c06f9ef003fd8129e408d", + "reference": "aa8fe766f772f9233d6c06f9ef003fd8129e408d", "shasum": "" }, "require": { "ext-json": "*", "ext-openssl": "*", - "guzzlehttp/guzzle": "^6.0|^7.0", - "guzzlehttp/psr7": "^1.7|^2.0", + "guzzlehttp/guzzle": "^6.5.8||^7.8.2||^8.0", + "guzzlehttp/psr7": "^1.9.1||^2.6.3||^3.0", "php": ">=7.1||>=8.0" }, "require-dev": { @@ -3617,9 +3633,9 @@ ], "support": { "issues": "https://github.com/thephpleague/oauth1-client/issues", - "source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0" + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.12.0" }, - "time": "2024-12-10T19:59:05+00:00" + "time": "2026-09-19T05:12:51+00:00" }, { "name": "league/uri", @@ -3805,16 +3821,16 @@ }, { "name": "livewire/livewire", - "version": "v3.8.7", + "version": "v3.8.10", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "ff019f8f6f48b7a2315922e45a70ad8fd75d1934" + "reference": "3adc7ff5d0898876f9848ab155310a296c717c1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/ff019f8f6f48b7a2315922e45a70ad8fd75d1934", - "reference": "ff019f8f6f48b7a2315922e45a70ad8fd75d1934", + "url": "https://api.github.com/repos/livewire/livewire/zipball/3adc7ff5d0898876f9848ab155310a296c717c1b", + "reference": "3adc7ff5d0898876f9848ab155310a296c717c1b", "shasum": "" }, "require": { @@ -3869,7 +3885,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.8.7" + "source": "https://github.com/livewire/livewire/tree/v3.8.10" }, "funding": [ { @@ -3877,7 +3893,7 @@ "type": "github" } ], - "time": "2026-08-31T15:40:46+00:00" + "time": "2026-09-28T18:12:47+00:00" }, { "name": "log1x/laravel-webfonts", @@ -3943,16 +3959,16 @@ }, { "name": "lorisleiva/laravel-actions", - "version": "v2.10.2", + "version": "v2.12.0", "source": { "type": "git", "url": "https://github.com/lorisleiva/laravel-actions.git", - "reference": "40da1bbd3cf115f4e885c197b91777d596c9317a" + "reference": "8d58b11d4fbca08fbcdb87243a4607bb3ae1db8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lorisleiva/laravel-actions/zipball/40da1bbd3cf115f4e885c197b91777d596c9317a", - "reference": "40da1bbd3cf115f4e885c197b91777d596c9317a", + "url": "https://api.github.com/repos/lorisleiva/laravel-actions/zipball/8d58b11d4fbca08fbcdb87243a4607bb3ae1db8d", + "reference": "8d58b11d4fbca08fbcdb87243a4607bb3ae1db8d", "shasum": "" }, "require": { @@ -3961,8 +3977,13 @@ "php": "^8.2" }, "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "larastan/larastan": "^3.0", "orchestra/testbench": "^9.0|^10.0|^11.0", "pestphp/pest": "^3.0|^4.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^11.5|^12.0" }, "type": "library", @@ -3974,6 +3995,11 @@ "providers": [ "Lorisleiva\\Actions\\ActionServiceProvider" ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] } }, "autoload": { @@ -4007,7 +4033,7 @@ ], "support": { "issues": "https://github.com/lorisleiva/laravel-actions/issues", - "source": "https://github.com/lorisleiva/laravel-actions/tree/v2.10.2" + "source": "https://github.com/lorisleiva/laravel-actions/tree/v2.12.0" }, "funding": [ { @@ -4015,20 +4041,20 @@ "type": "github" } ], - "time": "2026-07-26T11:03:26+00:00" + "time": "2026-08-25T14:17:11+00:00" }, { "name": "lorisleiva/lody", - "version": "v0.7.0", + "version": "v0.7.1", "source": { "type": "git", "url": "https://github.com/lorisleiva/lody.git", - "reference": "82ecb6faa55fb20109e6959f42f0f652cd77674b" + "reference": "018d3d384872d4b28f5194dae5fd05894db7fabe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lorisleiva/lody/zipball/82ecb6faa55fb20109e6959f42f0f652cd77674b", - "reference": "82ecb6faa55fb20109e6959f42f0f652cd77674b", + "url": "https://api.github.com/repos/lorisleiva/lody/zipball/018d3d384872d4b28f5194dae5fd05894db7fabe", + "reference": "018d3d384872d4b28f5194dae5fd05894db7fabe", "shasum": "" }, "require": { @@ -4079,7 +4105,7 @@ ], "support": { "issues": "https://github.com/lorisleiva/lody/issues", - "source": "https://github.com/lorisleiva/lody/tree/v0.7.0" + "source": "https://github.com/lorisleiva/lody/tree/v0.7.1" }, "funding": [ { @@ -4087,20 +4113,20 @@ "type": "github" } ], - "time": "2026-03-18T12:49:31+00:00" + "time": "2026-09-01T11:53:49+00:00" }, { "name": "monolog/monolog", - "version": "3.10.0", + "version": "3.12.1", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/d7059e4c691d05ea1222b5124bed0f961be46e15", + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15", "shasum": "" }, "require": { @@ -4126,6 +4152,7 @@ "phpstan/phpstan-strict-rules": "^2", "phpunit/phpunit": "^10.5.17 || ^11.0.7", "predis/predis": "^1.1 || ^2", + "psr/clock": "^1.0", "rollbar/rollbar": "^4.0", "ruflin/elastica": "^7 || ^8", "symfony/mailer": "^5.4 || ^6", @@ -4144,6 +4171,7 @@ "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "psr/clock": "Required to pass a clock to the Logger and control the timestamp of log records", "rollbar/rollbar": "Allow sending log messages to Rollbar", "ruflin/elastica": "Allow sending log messages to an Elastic Search server" }, @@ -4178,7 +4206,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + "source": "https://github.com/Seldaek/monolog/tree/3.12.1" }, "funding": [ { @@ -4190,7 +4218,7 @@ "type": "tidelift" } ], - "time": "2026-01-02T08:56:05+00:00" + "time": "2026-09-29T19:21:12+00:00" }, { "name": "mtdowling/jmespath.php", @@ -4260,16 +4288,16 @@ }, { "name": "nesbot/carbon", - "version": "3.13.2", + "version": "3.14.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" + "reference": "34e9109535165bf1d7ff17a610492372ae73a896" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", - "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/34e9109535165bf1d7ff17a610492372ae73a896", + "reference": "34e9109535165bf1d7ff17a610492372ae73a896", "shasum": "" }, "require": { @@ -4361,7 +4389,7 @@ "type": "tidelift" } ], - "time": "2026-08-08T11:40:35+00:00" + "time": "2026-09-27T12:07:26+00:00" }, { "name": "nette/schema", @@ -4523,16 +4551,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.8.0", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", "shasum": "" }, "require": { @@ -4574,9 +4602,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" }, - "time": "2026-07-04T14:30:18+00:00" + "time": "2026-09-13T18:51:52+00:00" }, { "name": "nubs/random-name-generator", @@ -4917,20 +4945,25 @@ }, { "name": "phpdocumentor/reflection-common", - "version": "2.2.0", + "version": "2.2.1", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + "reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/ee15a5a2022c8383906b225055e9496a6a5e6e3b", + "reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.5", + "vimeo/psalm": "^4.25" }, "type": "library", "extra": { @@ -4954,7 +4987,7 @@ } ], "description": "Common reflection classes used by phpdocumentor to reflect the code structure", - "homepage": "http://www.phpdoc.org", + "homepage": "https://www.phpdoc.org", "keywords": [ "FQSEN", "phpDocumentor", @@ -4964,9 +4997,9 @@ ], "support": { "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", - "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.2.1" }, - "time": "2020-06-27T09:03:43+00:00" + "time": "2025-11-10T21:21:55+00:00" }, { "name": "phpdocumentor/reflection-docblock", @@ -5035,16 +5068,16 @@ }, { "name": "phpdocumentor/type-resolver", - "version": "2.0.0", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + "reference": "dbe2133bb23e2e33a6834f4ed417f24a1b167769" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", - "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/dbe2133bb23e2e33a6834f4ed417f24a1b167769", + "reference": "dbe2133bb23e2e33a6834f4ed417f24a1b167769", "shasum": "" }, "require": { @@ -5087,22 +5120,22 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.1.0" }, - "time": "2026-01-06T21:53:42+00:00" + "time": "2026-09-27T19:32:54+00:00" }, { "name": "phpoption/phpoption", - "version": "1.9.5", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", "shasum": "" }, "require": { @@ -5110,7 +5143,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" }, "type": "library", "extra": { @@ -5152,7 +5185,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" }, "funding": [ { @@ -5164,7 +5197,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:41:33+00:00" + "time": "2026-08-24T00:54:40+00:00" }, { "name": "phpseclib/phpseclib", @@ -5278,16 +5311,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.3", + "version": "2.3.6", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + "reference": "1427af4647235b4a73b13940b1f1258d3584f36e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1427af4647235b4a73b13940b1f1258d3584f36e", + "reference": "1427af4647235b4a73b13940b1f1258d3584f36e", "shasum": "" }, "require": { @@ -5319,9 +5352,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.6" }, - "time": "2026-07-08T07:01:06+00:00" + "time": "2026-09-27T20:07:45+00:00" }, { "name": "pion/laravel-chunk-upload", @@ -5438,16 +5471,16 @@ }, { "name": "pragmarx/google2fa", - "version": "v9.0.0", + "version": "v9.1.0", "source": { "type": "git", "url": "https://github.com/antonioribeiro/google2fa.git", - "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" + "reference": "f00bc788c555adfb6765c437ff3538e59cd88af1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", - "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/f00bc788c555adfb6765c437ff3538e59cd88af1", + "reference": "f00bc788c555adfb6765c437ff3538e59cd88af1", "shasum": "" }, "require": { @@ -5455,8 +5488,11 @@ "php": "^7.1|^8.0" }, "require-dev": { - "phpstan/phpstan": "^1.9", - "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + "phpstan/phpstan": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.0|^2.0", + "phpunit/phpunit": "~9|~10|~11|~12|~13", + "psalm/plugin-phpunit": "^0.19|^0.20", + "vimeo/psalm": "^5.26|^6.13" }, "type": "library", "autoload": { @@ -5479,14 +5515,23 @@ "keywords": [ "2fa", "Authentication", + "MFA", "Two Factor Authentication", - "google2fa" + "google-authenticator", + "google2fa", + "hotp", + "otp", + "rfc4226", + "rfc6238", + "totp" ], "support": { + "docs": "https://github.com/antonioribeiro/google2fa#readme", "issues": "https://github.com/antonioribeiro/google2fa/issues", - "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0" + "security": "https://github.com/antonioribeiro/google2fa/security/policy", + "source": "https://github.com/antonioribeiro/google2fa" }, - "time": "2025-09-19T22:51:08+00:00" + "time": "2026-08-15T13:22:01+00:00" }, { "name": "psr/cache", @@ -6030,16 +6075,16 @@ }, { "name": "purplepixie/phpdns", - "version": "2.3.6", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/purplepixie/phpdns.git", - "reference": "59e064513849adb426b416fef0717178a0a68a2d" + "reference": "51c838f6fd46a237d2ffa6fc12ecff6391ebcb20" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/purplepixie/phpdns/zipball/59e064513849adb426b416fef0717178a0a68a2d", - "reference": "59e064513849adb426b416fef0717178a0a68a2d", + "url": "https://api.github.com/repos/purplepixie/phpdns/zipball/51c838f6fd46a237d2ffa6fc12ecff6391ebcb20", + "reference": "51c838f6fd46a237d2ffa6fc12ecff6391ebcb20", "shasum": "" }, "require": { @@ -6072,9 +6117,9 @@ "description": "PHP DNS Direct Query Module", "support": { "issues": "https://github.com/purplepixie/phpdns/issues", - "source": "https://github.com/purplepixie/phpdns/tree/2.3.6" + "source": "https://github.com/purplepixie/phpdns/tree/2.4.0" }, - "time": "2025-11-03T12:17:11+00:00" + "time": "2026-09-06T10:05:57+00:00" }, { "name": "pusher/pusher-php-server", @@ -6139,6 +6184,68 @@ }, "time": "2026-08-07T12:47:11+00:00" }, + { + "name": "radebatz/type-info-extras", + "version": "1.0.9", + "source": { + "type": "git", + "url": "https://github.com/DerManoMann/type-info-extras.git", + "reference": "2d9f01d60d642f890d9f170c0d8fde1f75ab17fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DerManoMann/type-info-extras/zipball/2d9f01d60d642f890d9f170c0d8fde1f75ab17fb", + "reference": "2d9f01d60d642f890d9f170c0d8fde1f75ab17fb", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "phpstan/phpdoc-parser": "^2.0", + "symfony/type-info": "^7.3.8 || ^7.4.1 || ^8.0 || ^8.1-@dev" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.70", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Radebatz\\TypeInfoExtras\\": "src" + }, + "exclude-from-classmap": [ + "/tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Martin Rademacher", + "email": "mano@radebatz.org" + } + ], + "description": "Extras for symfony/type-info", + "homepage": "http://radebatz.net/mano/", + "keywords": [ + "component", + "symfony", + "type-info", + "types" + ], + "support": { + "issues": "https://github.com/DerManoMann/type-info-extras/issues", + "source": "https://github.com/DerManoMann/type-info-extras/tree/1.0.9" + }, + "time": "2026-09-29T22:04:31+00:00" + }, { "name": "ralouphie/getallheaders", "version": "3.0.3", @@ -6261,20 +6368,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.3", + "version": "4.9.4", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", "shasum": "" }, "require": { - "brick/math": ">=0.8.16 <=0.18", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -6333,9 +6440,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.3" + "source": "https://github.com/ramsey/uuid/tree/4.9.4" }, - "time": "2026-06-18T03:57:49+00:00" + "time": "2026-09-16T11:39:30+00:00" }, { "name": "ratchet/rfc6455", @@ -6928,16 +7035,16 @@ }, { "name": "resend/resend-laravel", - "version": "v1.5.0", + "version": "v1.6.0", "source": { "type": "git", "url": "https://github.com/resend/resend-laravel.git", - "reference": "3f67df5b6f61353b09d7f849e6c81df8cdbc079f" + "reference": "56d09b18c8850396a36a7f81e3d98365fe51bc6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/resend/resend-laravel/zipball/3f67df5b6f61353b09d7f849e6c81df8cdbc079f", - "reference": "3f67df5b6f61353b09d7f849e6c81df8cdbc079f", + "url": "https://api.github.com/repos/resend/resend-laravel/zipball/56d09b18c8850396a36a7f81e3d98365fe51bc6b", + "reference": "56d09b18c8850396a36a7f81e3d98365fe51bc6b", "shasum": "" }, "require": { @@ -6991,9 +7098,9 @@ ], "support": { "issues": "https://github.com/resend/resend-laravel/issues", - "source": "https://github.com/resend/resend-laravel/tree/v1.5.0" + "source": "https://github.com/resend/resend-laravel/tree/v1.6.0" }, - "time": "2026-09-17T20:41:15+00:00" + "time": "2026-09-29T18:05:25+00:00" }, { "name": "resend/resend-php", @@ -7056,16 +7163,16 @@ }, { "name": "sentry/sentry", - "version": "4.30.0", + "version": "4.32.0", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-php.git", - "reference": "95a26e7c946651cacd69d1ded95a8190f952a3ae" + "reference": "c345642bdc0b678965a35ab61fc6323808eb3d2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/95a26e7c946651cacd69d1ded95a8190f952a3ae", - "reference": "95a26e7c946651cacd69d1ded95a8190f952a3ae", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/c345642bdc0b678965a35ab61fc6323808eb3d2c", + "reference": "c345642bdc0b678965a35ab61fc6323808eb3d2c", "shasum": "" }, "require": { @@ -7136,7 +7243,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-php/issues", - "source": "https://github.com/getsentry/sentry-php/tree/4.30.0" + "source": "https://github.com/getsentry/sentry-php/tree/4.32.0" }, "funding": [ { @@ -7148,27 +7255,27 @@ "type": "custom" } ], - "time": "2026-08-05T14:54:26+00:00" + "time": "2026-09-24T13:32:44+00:00" }, { "name": "sentry/sentry-laravel", - "version": "4.27.0", + "version": "4.28.0", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-laravel.git", - "reference": "caa0ff39805a2eb8c70c4535a1443d7421c5a5b8" + "reference": "34bf18fbf99b884a9bfbb46957d0178d2d0b988e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/caa0ff39805a2eb8c70c4535a1443d7421c5a5b8", - "reference": "caa0ff39805a2eb8c70c4535a1443d7421c5a5b8", + "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/34bf18fbf99b884a9bfbb46957d0178d2d0b988e", + "reference": "34bf18fbf99b884a9bfbb46957d0178d2d0b988e", "shasum": "" }, "require": { "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0", "nyholm/psr7": "^1.0", "php": "^7.2 | ^8.0", - "sentry/sentry": "^4.28.0", + "sentry/sentry": "^4.31.0", "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0" }, "require-dev": { @@ -7228,7 +7335,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-laravel/issues", - "source": "https://github.com/getsentry/sentry-laravel/tree/4.27.0" + "source": "https://github.com/getsentry/sentry-laravel/tree/4.28.0" }, "funding": [ { @@ -7240,7 +7347,7 @@ "type": "custom" } ], - "time": "2026-07-15T14:47:03+00:00" + "time": "2026-09-23T14:29:19+00:00" }, { "name": "socialiteproviders/authentik", @@ -7486,26 +7593,26 @@ }, { "name": "socialiteproviders/manager", - "version": "4.9.2", + "version": "4.10.0", "source": { "type": "git", "url": "https://github.com/SocialiteProviders/Manager.git", - "reference": "35372dc62787e61e91cfec73f45fd5d5ae0f8891" + "reference": "65ad770276875682e246e5435566146bf0baebf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/35372dc62787e61e91cfec73f45fd5d5ae0f8891", - "reference": "35372dc62787e61e91cfec73f45fd5d5ae0f8891", + "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/65ad770276875682e246e5435566146bf0baebf2", + "reference": "65ad770276875682e246e5435566146bf0baebf2", "shasum": "" }, "require": { - "illuminate/support": "^11.0 || ^12.0 || ^13.0", - "laravel/socialite": "^5.5", - "php": "^8.2" + "illuminate/support": "^12.0 || ^13.0", + "laravel/socialite": "^5.29", + "php": "^8.3" }, "require-dev": { - "mockery/mockery": "^1.2", - "phpunit/phpunit": "^9.0" + "mockery/mockery": "^1.6", + "phpunit/phpunit": "^12.5" }, "type": "library", "extra": { @@ -7556,24 +7663,23 @@ "issues": "https://github.com/socialiteproviders/manager/issues", "source": "https://github.com/socialiteproviders/manager" }, - "time": "2026-03-18T22:13:24+00:00" + "time": "2026-08-16T00:16:22+00:00" }, { "name": "socialiteproviders/microsoft-azure", - "version": "5.2.0", + "version": "5.2.1", "source": { "type": "git", "url": "https://github.com/SocialiteProviders/Microsoft-Azure.git", - "reference": "453d62c9d7e3b3b76e94c913fb46e68a33347b16" + "reference": "d70ea3c60b09cc113a14350761a0618815bfa11c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Microsoft-Azure/zipball/453d62c9d7e3b3b76e94c913fb46e68a33347b16", - "reference": "453d62c9d7e3b3b76e94c913fb46e68a33347b16", + "url": "https://api.github.com/repos/SocialiteProviders/Microsoft-Azure/zipball/d70ea3c60b09cc113a14350761a0618815bfa11c", + "reference": "d70ea3c60b09cc113a14350761a0618815bfa11c", "shasum": "" }, "require": { - "ext-json": "*", "php": "^8.0", "socialiteproviders/manager": "^4.4" }, @@ -7607,7 +7713,7 @@ "issues": "https://github.com/socialiteproviders/providers/issues", "source": "https://github.com/socialiteproviders/providers" }, - "time": "2024-03-15T03:02:10+00:00" + "time": "2026-08-23T02:40:58+00:00" }, { "name": "socialiteproviders/zitadel", @@ -7972,16 +8078,16 @@ }, { "name": "spatie/laravel-package-tools", - "version": "1.93.1", + "version": "1.93.3", "source": { "type": "git", "url": "https://github.com/spatie/laravel-package-tools.git", - "reference": "d5552849801f2642aea710557463234b59ef65eb" + "reference": "7aeef26bd8d4909bec744dbf47aee7d1395588c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb", - "reference": "d5552849801f2642aea710557463234b59ef65eb", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/7aeef26bd8d4909bec744dbf47aee7d1395588c1", + "reference": "7aeef26bd8d4909bec744dbf47aee7d1395588c1", "shasum": "" }, "require": { @@ -8021,7 +8127,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-package-tools/issues", - "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1" + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.3" }, "funding": [ { @@ -8029,7 +8135,7 @@ "type": "github" } ], - "time": "2026-05-19T14:06:37+00:00" + "time": "2026-09-28T07:16:50+00:00" }, { "name": "spatie/laravel-schemaless-attributes", @@ -8366,22 +8472,23 @@ }, { "name": "spomky-labs/cbor-php", - "version": "3.3.0", + "version": "3.4.2", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/cbor-php.git", - "reference": "013d13da69cf28b1ae501887daceccc850ca1c76" + "reference": "8f5ea00a07ad529d20886505cdbeb2b9ac7bb2d6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/013d13da69cf28b1ae501887daceccc850ca1c76", - "reference": "013d13da69cf28b1ae501887daceccc850ca1c76", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/8f5ea00a07ad529d20886505cdbeb2b9ac7bb2d6", + "reference": "8f5ea00a07ad529d20886505cdbeb2b9ac7bb2d6", "shasum": "" }, "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20|^1.0", "ext-mbstring": "*", - "php": ">=8.0" + "php": ">=8.0", + "symfony/polyfill-php81": "^1.32" }, "require-dev": { "ext-json": "*", @@ -8390,8 +8497,8 @@ "symfony/var-dumper": "^6.4|^7.1|^8.0" }, "suggest": { - "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags", - "ext-gmp": "GMP or BCMath extensions will drastically improve the library performance" + "ext-bcmath": "Improves the library performance when ext-gmp is missing, and is required to handle the Big Float and Decimal Fraction Tags (4 and 5)", + "ext-gmp": "Strongly recommended when decoding untrusted input: without it, converting the byte string of a Big Number Tag (2 and 3) is quadratic in its length. Also improves the library performance overall" }, "type": "library", "autoload": { @@ -8421,7 +8528,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/cbor-php/issues", - "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.3.0" + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.4.2" }, "funding": [ { @@ -8433,24 +8540,24 @@ "type": "patreon" } ], - "time": "2026-07-15T18:56:27+00:00" + "time": "2026-09-15T06:55:29+00:00" }, { "name": "spomky-labs/pki-framework", - "version": "1.6.0", + "version": "1.6.3", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/pki-framework.git", - "reference": "80778a25426288acd2e3a7cde2def41a3d59cddf" + "reference": "792e909d4e387adffe3c4f404451c7d57a3d2022" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/80778a25426288acd2e3a7cde2def41a3d59cddf", - "reference": "80778a25426288acd2e3a7cde2def41a3d59cddf", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/792e909d4e387adffe3c4f404451c7d57a3d2022", + "reference": "792e909d4e387adffe3c4f404451c7d57a3d2022", "shasum": "" }, "require": { - "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19", + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20|^1.0", "ext-mbstring": "*", "php": ">=8.1" }, @@ -8475,7 +8582,8 @@ "suggest": { "ext-bcmath": "For better performance (or GMP)", "ext-gmp": "For better performance (or BCMath)", - "ext-openssl": "For OpenSSL based cyphering" + "ext-openssl": "For OpenSSL based cyphering", + "ext-sodium": "To verify Ed25519 signatures where the OpenSSL extension has no EdDSA" }, "type": "library", "autoload": { @@ -8530,7 +8638,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/pki-framework/issues", - "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.0" + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.3" }, "funding": [ { @@ -8542,7 +8650,7 @@ "type": "patreon" } ], - "time": "2026-08-06T16:21:11+00:00" + "time": "2026-09-12T19:02:49+00:00" }, { "name": "stevebauman/purify", @@ -8748,16 +8856,16 @@ }, { "name": "symfony/console", - "version": "v7.4.16", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "f4c69c9aed03abf933b294257d618bdd9b30a06d" + "reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/f4c69c9aed03abf933b294257d618bdd9b30a06d", - "reference": "f4c69c9aed03abf933b294257d618bdd9b30a06d", + "url": "https://api.github.com/repos/symfony/console/zipball/d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d", + "reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d", "shasum": "" }, "require": { @@ -8822,7 +8930,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.16" + "source": "https://github.com/symfony/console/tree/v7.4.20" }, "funding": [ { @@ -8842,20 +8950,20 @@ "type": "tidelift" } ], - "time": "2026-07-31T12:37:14+00:00" + "time": "2026-09-18T15:55:00+00:00" }, { "name": "symfony/css-selector", - "version": "v8.1.0", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", - "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/08e2905152a39cf3fd1745d83f8c483e258887d9", + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9", "shasum": "" }, "require": { @@ -8891,7 +8999,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.1.0" + "source": "https://github.com/symfony/css-selector/tree/v8.1.6" }, "funding": [ { @@ -8911,7 +9019,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-23T10:06:25+00:00" }, { "name": "symfony/deprecation-contracts", @@ -8986,16 +9094,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.15", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "d49f6a19f326db41ae7103bdc38e3eb35a791261" + "reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/d49f6a19f326db41ae7103bdc38e3eb35a791261", - "reference": "d49f6a19f326db41ae7103bdc38e3eb35a791261", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0", + "reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0", "shasum": "" }, "require": { @@ -9044,7 +9152,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.15" + "source": "https://github.com/symfony/error-handler/tree/v7.4.20" }, "funding": [ { @@ -9064,7 +9172,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T15:13:06+00:00" + "time": "2026-09-22T18:56:40+00:00" }, { "name": "symfony/event-dispatcher", @@ -9234,16 +9342,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.1.2", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "17856b7a222664a26a5ea1cb06ee0721c2438217" + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/17856b7a222664a26a5ea1cb06ee0721c2438217", - "reference": "17856b7a222664a26a5ea1cb06ee0721c2438217", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7599ebb855fede59413ddb7f15198d67bfcae7ba", + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba", "shasum": "" }, "require": { @@ -9281,7 +9389,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.1.2" + "source": "https://github.com/symfony/filesystem/tree/v8.1.6" }, "funding": [ { @@ -9301,20 +9409,20 @@ "type": "tidelift" } ], - "time": "2026-07-22T15:42:13+00:00" + "time": "2026-08-23T10:06:25+00:00" }, { "name": "symfony/finder", - "version": "v7.4.14", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "13b38720174286f55d1761152b575a8d1436fc25" + "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", - "reference": "13b38720174286f55d1761152b575a8d1436fc25", + "url": "https://api.github.com/repos/symfony/finder/zipball/2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", + "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", "shasum": "" }, "require": { @@ -9349,7 +9457,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.14" + "source": "https://github.com/symfony/finder/tree/v7.4.20" }, "funding": [ { @@ -9369,20 +9477,20 @@ "type": "tidelift" } ], - "time": "2026-06-27T08:31:18+00:00" + "time": "2026-09-26T06:31:10+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.16", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "b676451bb638e99a7d34d8a2be90406822e301eb" + "reference": "9d0761c0da6bd5b801b401666834901a9a1ffc33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/b676451bb638e99a7d34d8a2be90406822e301eb", - "reference": "b676451bb638e99a7d34d8a2be90406822e301eb", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9d0761c0da6bd5b801b401666834901a9a1ffc33", + "reference": "9d0761c0da6bd5b801b401666834901a9a1ffc33", "shasum": "" }, "require": { @@ -9431,7 +9539,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.16" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.20" }, "funding": [ { @@ -9451,20 +9559,20 @@ "type": "tidelift" } ], - "time": "2026-08-07T11:50:27+00:00" + "time": "2026-09-24T07:12:44+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.16", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "f5e728670fa2218ae8be8ea91f2b44b7d6e5304c" + "reference": "392784b5d7b959b8d872542a2530c50c540a788f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f5e728670fa2218ae8be8ea91f2b44b7d6e5304c", - "reference": "f5e728670fa2218ae8be8ea91f2b44b7d6e5304c", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/392784b5d7b959b8d872542a2530c50c540a788f", + "reference": "392784b5d7b959b8d872542a2530c50c540a788f", "shasum": "" }, "require": { @@ -9550,7 +9658,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.16" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.20" }, "funding": [ { @@ -9570,7 +9678,7 @@ "type": "tidelift" } ], - "time": "2026-08-07T18:00:13+00:00" + "time": "2026-09-29T14:05:16+00:00" }, { "name": "symfony/mailer", @@ -9901,16 +10009,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.41.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", - "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", "shasum": "" }, "require": { @@ -9959,7 +10067,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" }, "funding": [ { @@ -9979,20 +10087,20 @@ "type": "tidelift" } ], - "time": "2026-07-28T08:25:59+00:00" + "time": "2026-09-25T15:46:47+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.42.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", - "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/533d68cf99c8f8ee2e8893c59ed59192e92cfd74", + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74", "shasum": "" }, "require": { @@ -10046,7 +10154,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.43.0" }, "funding": [ { @@ -10066,20 +10174,20 @@ "type": "tidelift" } ], - "time": "2026-08-24T10:51:20+00:00" + "time": "2026-09-23T14:40:04+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.42.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", - "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", "shasum": "" }, "require": { @@ -10131,7 +10239,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" }, "funding": [ { @@ -10151,20 +10259,20 @@ "type": "tidelift" } ], - "time": "2026-08-07T06:33:24+00:00" + "time": "2026-09-23T22:02:09+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.2", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", "shasum": "" }, "require": { @@ -10216,7 +10324,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" }, "funding": [ { @@ -10236,20 +10344,20 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:59:30+00:00" + "time": "2026-09-24T15:19:04+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.37.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", "shasum": "" }, "require": { @@ -10300,7 +10408,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" }, "funding": [ { @@ -10320,20 +10428,100 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-09-25T15:50:05+00:00" }, { - "name": "symfony/polyfill-php83", - "version": "v1.41.0", + "name": "symfony/polyfill-php81", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", - "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:45:58+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce", "shasum": "" }, "require": { @@ -10380,7 +10568,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.43.0" }, "funding": [ { @@ -10400,20 +10588,20 @@ "type": "tidelift" } ], - "time": "2026-07-01T12:47:55+00:00" + "time": "2026-09-25T15:50:05+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.38.1", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", - "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/22c6548499cbe5c5bb072fc0c42312dff062a015", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015", "shasum": "" }, "require": { @@ -10460,7 +10648,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.43.0" }, "funding": [ { @@ -10480,20 +10668,20 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-09-23T22:01:09+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.41.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/86a42cbcc07adc331481a5ab310a4024bfc227f6", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6", "shasum": "" }, "require": { @@ -10540,7 +10728,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.43.0" }, "funding": [ { @@ -10560,20 +10748,20 @@ "type": "tidelift" } ], - "time": "2026-07-01T12:47:55+00:00" + "time": "2026-09-25T15:46:47+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.37.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + "reference": "b23ff8245cc5c4479ea2be3d0132d2e6dd433993" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", - "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/b23ff8245cc5c4479ea2be3d0132d2e6dd433993", + "reference": "b23ff8245cc5c4479ea2be3d0132d2e6dd433993", "shasum": "" }, "require": { @@ -10623,7 +10811,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.43.0" }, "funding": [ { @@ -10643,20 +10831,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-09-24T05:20:52+00:00" }, { "name": "symfony/process", - "version": "v7.4.13", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "f5804be144caceb570f6747519999636b664f24c" + "reference": "ed0ae095b86994d370d5791612e55984f15aa30e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", - "reference": "f5804be144caceb570f6747519999636b664f24c", + "url": "https://api.github.com/repos/symfony/process/zipball/ed0ae095b86994d370d5791612e55984f15aa30e", + "reference": "ed0ae095b86994d370d5791612e55984f15aa30e", "shasum": "" }, "require": { @@ -10688,7 +10876,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.13" + "source": "https://github.com/symfony/process/tree/v7.4.19" }, "funding": [ { @@ -10708,7 +10896,7 @@ "type": "tidelift" } ], - "time": "2026-05-23T16:05:06+00:00" + "time": "2026-09-02T12:38:34+00:00" }, { "name": "symfony/property-access", @@ -10793,16 +10981,16 @@ }, { "name": "symfony/property-info", - "version": "v8.1.4", + "version": "v8.1.8", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "d3b1ba3e69dd9fbfff3e00416d2fc60c600c599d" + "reference": "fe727d4888a92ac9200a2c1b8e0a995b6022afa7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/d3b1ba3e69dd9fbfff3e00416d2fc60c600c599d", - "reference": "d3b1ba3e69dd9fbfff3e00416d2fc60c600c599d", + "url": "https://api.github.com/repos/symfony/property-info/zipball/fe727d4888a92ac9200a2c1b8e0a995b6022afa7", + "reference": "fe727d4888a92ac9200a2c1b8e0a995b6022afa7", "shasum": "" }, "require": { @@ -10855,7 +11043,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v8.1.4" + "source": "https://github.com/symfony/property-info/tree/v8.1.8" }, "funding": [ { @@ -10875,7 +11063,7 @@ "type": "tidelift" } ], - "time": "2026-08-07T15:02:39+00:00" + "time": "2026-09-28T19:13:15+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -10966,16 +11154,16 @@ }, { "name": "symfony/routing", - "version": "v7.4.15", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "80c0a93d3f8e7499f716204a1fb38ead942a7a2b" + "reference": "2bacb72be5eadeaf9874d5accb75bfb33d21c74b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/80c0a93d3f8e7499f716204a1fb38ead942a7a2b", - "reference": "80c0a93d3f8e7499f716204a1fb38ead942a7a2b", + "url": "https://api.github.com/repos/symfony/routing/zipball/2bacb72be5eadeaf9874d5accb75bfb33d21c74b", + "reference": "2bacb72be5eadeaf9874d5accb75bfb33d21c74b", "shasum": "" }, "require": { @@ -11027,7 +11215,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.15" + "source": "https://github.com/symfony/routing/tree/v7.4.20" }, "funding": [ { @@ -11047,20 +11235,20 @@ "type": "tidelift" } ], - "time": "2026-07-21T15:13:06+00:00" + "time": "2026-09-28T19:09:50+00:00" }, { "name": "symfony/serializer", - "version": "v8.1.4", + "version": "v8.1.8", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "ec3ae778e49a4cee5b937a779056fa23c3e834fb" + "reference": "df54c4fe859d945eb633d2c5e6a410ce535f21e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/ec3ae778e49a4cee5b937a779056fa23c3e834fb", - "reference": "ec3ae778e49a4cee5b937a779056fa23c3e834fb", + "url": "https://api.github.com/repos/symfony/serializer/zipball/df54c4fe859d945eb633d2c5e6a410ce535f21e4", + "reference": "df54c4fe859d945eb633d2c5e6a410ce535f21e4", "shasum": "" }, "require": { @@ -11126,7 +11314,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v8.1.4" + "source": "https://github.com/symfony/serializer/tree/v8.1.8" }, "funding": [ { @@ -11146,7 +11334,7 @@ "type": "tidelift" } ], - "time": "2026-08-06T09:53:08+00:00" + "time": "2026-09-28T09:40:25+00:00" }, { "name": "symfony/service-contracts", @@ -11237,16 +11425,16 @@ }, { "name": "symfony/string", - "version": "v8.1.2", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", - "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "url": "https://api.github.com/repos/symfony/string/zipball/d950140b5f56f31901e5b7a0c04ffc3a3deb943c", + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c", "shasum": "" }, "require": { @@ -11303,7 +11491,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.2" + "source": "https://github.com/symfony/string/tree/v8.1.7" }, "funding": [ { @@ -11323,20 +11511,20 @@ "type": "tidelift" } ], - "time": "2026-07-28T07:35:25+00:00" + "time": "2026-09-11T14:51:16+00:00" }, { "name": "symfony/translation", - "version": "v8.1.4", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "c0955eb4aa417a110e65c8162237b8a5c7d910bf" + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/c0955eb4aa417a110e65c8162237b8a5c7d910bf", - "reference": "c0955eb4aa417a110e65c8162237b8a5c7d910bf", + "url": "https://api.github.com/repos/symfony/translation/zipball/d9e1caba0d6b6f9a26710af8a2f88d37f001215a", + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a", "shasum": "" }, "require": { @@ -11396,7 +11584,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.1.4" + "source": "https://github.com/symfony/translation/tree/v8.1.5" }, "funding": [ { @@ -11416,7 +11604,7 @@ "type": "tidelift" } ], - "time": "2026-07-30T12:40:56+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/translation-contracts", @@ -11502,16 +11690,16 @@ }, { "name": "symfony/type-info", - "version": "v8.1.0", + "version": "v8.1.8", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7" + "reference": "18e1d891f0b8776141f281f7b6f5e67e4df9d9cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", - "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "url": "https://api.github.com/repos/symfony/type-info/zipball/18e1d891f0b8776141f281f7b6f5e67e4df9d9cd", + "reference": "18e1d891f0b8776141f281f7b6f5e67e4df9d9cd", "shasum": "" }, "require": { @@ -11560,7 +11748,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v8.1.0" + "source": "https://github.com/symfony/type-info/tree/v8.1.8" }, "funding": [ { @@ -11580,20 +11768,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-09-28T19:13:15+00:00" }, { "name": "symfony/uid", - "version": "v7.4.9", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "2676b524340abcfe4d6151ec698463cebafee439" + "reference": "a96bbdb02a04ff3b5592cee6741b03edc78b56aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", - "reference": "2676b524340abcfe4d6151ec698463cebafee439", + "url": "https://api.github.com/repos/symfony/uid/zipball/a96bbdb02a04ff3b5592cee6741b03edc78b56aa", + "reference": "a96bbdb02a04ff3b5592cee6741b03edc78b56aa", "shasum": "" }, "require": { @@ -11638,7 +11826,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.9" + "source": "https://github.com/symfony/uid/tree/v7.4.20" }, "funding": [ { @@ -11658,20 +11846,20 @@ "type": "tidelift" } ], - "time": "2026-04-30T15:19:22+00:00" + "time": "2026-09-23T16:18:28+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.15", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd" + "reference": "e088da50b813f32473a76871616cbb8fa54653a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/04ba4add636a95ff437af3a5a9499bb1d6c6d4bd", - "reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e088da50b813f32473a76871616cbb8fa54653a8", + "reference": "e088da50b813f32473a76871616cbb8fa54653a8", "shasum": "" }, "require": { @@ -11725,7 +11913,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.15" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.18" }, "funding": [ { @@ -11745,32 +11933,32 @@ "type": "tidelift" } ], - "time": "2026-07-21T15:13:06+00:00" + "time": "2026-08-30T20:10:52+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.15", + "version": "v8.1.8", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "e101850ded5d2c0d44bf32abb8996404afec2dec" + "reference": "030e96eed0303223bfca3af9109e9e64830b6ec3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/e101850ded5d2c0d44bf32abb8996404afec2dec", - "reference": "e101850ded5d2c0d44bf32abb8996404afec2dec", + "url": "https://api.github.com/repos/symfony/yaml/zipball/030e96eed0303223bfca3af9109e9e64830b6ec3", + "reference": "030e96eed0303223bfca3af9109e9e64830b6ec3", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<7.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" }, "bin": [ "Resources/bin/yaml-lint" @@ -11801,7 +11989,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.15" + "source": "https://github.com/symfony/yaml/tree/v8.1.8" }, "funding": [ { @@ -11821,7 +12009,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T15:13:06+00:00" + "time": "2026-09-26T06:33:49+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -11880,35 +12068,34 @@ }, { "name": "visus/cuid2", - "version": "6.0.0", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/visus-io/php-cuid2.git", - "reference": "834c8a1c04684931600ee7a4189150b331a5b56c" + "reference": "a495550b8a88a4813f6a15683eb74bbb60312fe1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/visus-io/php-cuid2/zipball/834c8a1c04684931600ee7a4189150b331a5b56c", - "reference": "834c8a1c04684931600ee7a4189150b331a5b56c", + "url": "https://api.github.com/repos/visus-io/php-cuid2/zipball/a495550b8a88a4813f6a15683eb74bbb60312fe1", + "reference": "a495550b8a88a4813f6a15683eb74bbb60312fe1", "shasum": "" }, "require": { - "php": "^8.2", - "symfony/polyfill-php83": "^1.32" + "php": "^8.3" }, "require-dev": { - "captainhook/captainhook": "^5.27", - "captainhook/hook-installer": "^1.0", - "captainhook/plugin-composer": "^5.3", - "dealerdirect/phpcodesniffer-composer-installer": "^1.2", - "ergebnis/composer-normalize": "^2.29", + "captainhook/captainhook": "^5.29.2", + "captainhook/hook-installer": "^1.0.5", + "captainhook/plugin-composer": "^5.3.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.2.1", + "ergebnis/composer-normalize": "^2.52", "php-parallel-lint/php-parallel-lint": "^1.4", - "phpbench/phpbench": "^1.4", - "phpstan/phpstan": "^1.9", - "phpunit/phpunit": "^10.5", - "ramsey/conventional-commits": "^1.5", - "slevomat/coding-standard": "^8.25", - "squizlabs/php_codesniffer": "^4.0" + "phpbench/phpbench": "^1.7", + "phpstan/phpstan": "^2.2.7", + "phpunit/phpunit": "^12.5.33", + "ramsey/conventional-commits": "^1.7", + "slevomat/coding-standard": "^8.31.1", + "squizlabs/php_codesniffer": "^4.0.1" }, "suggest": { "ext-gmp": "Enables faster math with arbitrary precision integers using GMP." @@ -11944,29 +12131,29 @@ ], "support": { "issues": "https://github.com/visus-io/php-cuid2/issues", - "source": "https://github.com/visus-io/php-cuid2/tree/6.0.0" + "source": "https://github.com/visus-io/php-cuid2/tree/7.0.0" }, - "time": "2025-12-18T14:52:27+00:00" + "time": "2026-09-21T10:58:46+00:00" }, { "name": "vlucas/phpdotenv", - "version": "v5.6.4", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" + "reference": "301c07936b16d88628b126b01d082ba153cf4c40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", - "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.4", + "graham-campbell/result-type": "^1.2", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5", + "phpoption/phpoption": "^1.10", "symfony/polyfill-ctype": "^1.26", "symfony/polyfill-mbstring": "^1.26", "symfony/polyfill-php80": "^1.26" @@ -12010,7 +12197,7 @@ "homepage": "https://github.com/vlucas" } ], - "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", "keywords": [ "dotenv", "env", @@ -12018,7 +12205,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" }, "funding": [ { @@ -12030,7 +12217,7 @@ "type": "tidelift" } ], - "time": "2026-07-06T19:11:50+00:00" + "time": "2026-08-24T18:07:49+00:00" }, { "name": "voku/portable-ascii", @@ -12108,32 +12295,36 @@ }, { "name": "web-auth/cose-lib", - "version": "4.6.0", + "version": "4.8.2", "source": { "type": "git", "url": "https://github.com/web-auth/cose-lib.git", - "reference": "3afe04df137baf97c5c3e28c5ee6f05536405148" + "reference": "8849e8bf043a2d42d0bec5bda5db2469ad376148" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3afe04df137baf97c5c3e28c5ee6f05536405148", - "reference": "3afe04df137baf97c5c3e28c5ee6f05536405148", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/8849e8bf043a2d42d0bec5bda5db2469ad376148", + "reference": "8849e8bf043a2d42d0bec5bda5db2469ad376148", "shasum": "" }, "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", + "brick/math": "^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "ext-json": "*", "ext-openssl": "*", "php": ">=8.1", "spomky-labs/pki-framework": "^1.0" }, + "conflict": { + "spomky-labs/cbor-php": "<3.4.0" + }, "require-dev": { - "spomky-labs/cbor-php": "^3.2.2" + "spomky-labs/cbor-php": "^3.4" }, "suggest": { - "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension", - "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension", - "spomky-labs/cbor-php": "For COSE Signature support" + "ext-bcmath": "Recommended: without GMP or BCMath, signing with RSASSA-PSS (PS256/PS384/PS512) blinds its private exponentiation in pure PHP", + "ext-gmp": "Recommended: without GMP or BCMath, signing with RSASSA-PSS (PS256/PS384/PS512) blinds its private exponentiation in pure PHP", + "ext-sodium": "Required by the EdDSA/Ed25519 signature algorithms (-8, -19, -260, -261) and to recompute an OKP public key from its private key", + "spomky-labs/cbor-php": "Required by the RFC 9052 header reader and cryptographic structures. 3.4.0 or later: it ships the six COSE message classes (CBOR\\Tag\\CoseSign1Tag and its siblings) that replace the deprecated Cose\\...Tag classes, and its decoder is what enforces the RFC 9052 label uniqueness and nesting bounds this library relies on" }, "type": "library", "autoload": { @@ -12163,7 +12354,7 @@ ], "support": { "issues": "https://github.com/web-auth/cose-lib/issues", - "source": "https://github.com/web-auth/cose-lib/tree/4.6.0" + "source": "https://github.com/web-auth/cose-lib/tree/4.8.2" }, "funding": [ { @@ -12175,20 +12366,20 @@ "type": "patreon" } ], - "time": "2026-07-16T10:19:49+00:00" + "time": "2026-09-15T17:30:33+00:00" }, { "name": "web-auth/webauthn-lib", - "version": "5.3.5", + "version": "5.3.9", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-lib.git", - "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f" + "reference": "727e378fb7a36c26be5c911e4a0120c146741ce7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f", - "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/727e378fb7a36c26be5c911e4a0120c146741ce7", + "reference": "727e378fb7a36c26be5c911e4a0120c146741ce7", "shasum": "" }, "require": { @@ -12200,7 +12391,7 @@ "psr/clock": "^1.0", "psr/event-dispatcher": "^1.0", "psr/log": "^1.0|^2.0|^3.0", - "spomky-labs/cbor-php": "^3.0", + "spomky-labs/cbor-php": "^3.4", "spomky-labs/pki-framework": "^1.0", "symfony/clock": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^3.2", @@ -12208,7 +12399,7 @@ "symfony/property-info": "^6.4|^7.0|^8.0", "symfony/serializer": "^6.4|^7.0|^8.0", "symfony/uid": "^6.4|^7.0|^8.0", - "web-auth/cose-lib": "^4.2.3" + "web-auth/cose-lib": "^4.8" }, "suggest": { "psr/log-implementation": "Recommended to receive logs from the library", @@ -12249,7 +12440,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.9" }, "funding": [ { @@ -12261,7 +12452,7 @@ "type": "patreon" } ], - "time": "2026-05-31T15:00:08+00:00" + "time": "2026-09-10T21:10:45+00:00" }, { "name": "webmozart/assert", @@ -12441,24 +12632,25 @@ }, { "name": "zircote/swagger-php", - "version": "5.8.3", + "version": "6.11.0", "source": { "type": "git", "url": "https://github.com/zircote/swagger-php.git", - "reference": "098223019f764a16715f64089a58606096719c98" + "reference": "f998f7e712658fba61f86fccbdcf8e301a982175" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zircote/swagger-php/zipball/098223019f764a16715f64089a58606096719c98", - "reference": "098223019f764a16715f64089a58606096719c98", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/f998f7e712658fba61f86fccbdcf8e301a982175", + "reference": "f998f7e712658fba61f86fccbdcf8e301a982175", "shasum": "" }, "require": { - "ext-json": "*", "nikic/php-parser": "^4.19 || ^5.0", - "php": ">=7.4", + "php": ">=8.2", "phpstan/phpdoc-parser": "^2.0", "psr/log": "^1.1 || ^2.0 || ^3.0", + "radebatz/type-info-extras": "^1.0.2", + "symfony/console": "^6.4 || ^7.0 || ^8.0", "symfony/deprecation-contracts": "^2 || ^3", "symfony/finder": "^5.0 || ^6.0 || ^7.0 || ^8.0", "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0" @@ -12467,17 +12659,11 @@ "symfony/process": ">=6, <6.4.14" }, "require-dev": { - "composer/package-versions-deprecated": "^1.11", "doctrine/annotations": "^2.0", - "friendsofphp/php-cs-fixer": "^3.62.0", - "phpstan/phpstan": "^1.6 || ^2.0", - "phpunit/phpunit": "^9.0", - "rector/rector": "^1.0 || ^2.3.1", - "vimeo/psalm": "^4.30 || ^5.0" - }, - "suggest": { - "doctrine/annotations": "^2.0", - "radebatz/type-info-extras": "^1.0.2" + "friendsofphp/php-cs-fixer": "3.95.23", + "phpstan/phpstan": "2.2.12", + "phpunit/phpunit": "^11.5 || >=12.5.22", + "rector/rector": "2.6.5" }, "bin": [ "bin/openapi" @@ -12485,7 +12671,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.x-dev" + "dev-master": "6.x-dev" } }, "autoload": { @@ -12523,7 +12709,7 @@ ], "support": { "issues": "https://github.com/zircote/swagger-php/issues", - "source": "https://github.com/zircote/swagger-php/tree/5.8.3" + "source": "https://github.com/zircote/swagger-php/tree/6.11.0" }, "funding": [ { @@ -12531,7 +12717,7 @@ "type": "github" } ], - "time": "2026-03-02T00:47:18+00:00" + "time": "2026-09-26T07:11:30+00:00" } ], "packages-dev": [ @@ -12758,16 +12944,16 @@ }, { "name": "amphp/dns", - "version": "v2.4.0", + "version": "v2.4.1", "source": { "type": "git", "url": "https://github.com/amphp/dns.git", - "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" + "reference": "ec5948bfafac808f410406e18bc7b52a2d4629b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", - "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "url": "https://api.github.com/repos/amphp/dns/zipball/ec5948bfafac808f410406e18bc7b52a2d4629b7", + "reference": "ec5948bfafac808f410406e18bc7b52a2d4629b7", "shasum": "" }, "require": { @@ -12786,7 +12972,7 @@ "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "phpunit/phpunit": "^9", - "psalm/phar": "5.20" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -12835,7 +13021,7 @@ ], "support": { "issues": "https://github.com/amphp/dns/issues", - "source": "https://github.com/amphp/dns/tree/v2.4.0" + "source": "https://github.com/amphp/dns/tree/v2.4.1" }, "funding": [ { @@ -12843,7 +13029,7 @@ "type": "github" } ], - "time": "2025-01-19T15:43:40+00:00" + "time": "2026-07-26T17:53:54+00:00" }, { "name": "amphp/hpack", @@ -13443,16 +13629,16 @@ }, { "name": "amphp/socket", - "version": "v2.4.0", + "version": "v2.4.1", "source": { "type": "git", "url": "https://github.com/amphp/socket.git", - "reference": "dadb63c5d3179fd83803e29dfeac27350e619314" + "reference": "b347be5aff6b2cc025208bb4d896607eb470c018" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314", - "reference": "dadb63c5d3179fd83803e29dfeac27350e619314", + "url": "https://api.github.com/repos/amphp/socket/zipball/b347be5aff6b2cc025208bb4d896607eb470c018", + "reference": "b347be5aff6b2cc025208bb4d896607eb470c018", "shasum": "" }, "require": { @@ -13515,7 +13701,7 @@ ], "support": { "issues": "https://github.com/amphp/socket/issues", - "source": "https://github.com/amphp/socket/tree/v2.4.0" + "source": "https://github.com/amphp/socket/tree/v2.4.1" }, "funding": [ { @@ -13523,7 +13709,7 @@ "type": "github" } ], - "time": "2026-04-19T15:09:56+00:00" + "time": "2026-08-22T18:13:20+00:00" }, { "name": "amphp/sync", @@ -14123,24 +14309,46 @@ }, { "name": "driftingly/rector-laravel", - "version": "2.5.0", + "version": "2.6.2", "source": { "type": "git", "url": "https://github.com/driftingly/rector-laravel.git", - "reference": "0dbdd842dfdbc821cfe5f47ca7326e2502b2a107" + "reference": "c0f9a2ff7365cec45d4dc64223608e7cec74604a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/0dbdd842dfdbc821cfe5f47ca7326e2502b2a107", - "reference": "0dbdd842dfdbc821cfe5f47ca7326e2502b2a107", + "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/c0f9a2ff7365cec45d4dc64223608e7cec74604a", + "reference": "c0f9a2ff7365cec45d4dc64223608e7cec74604a", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0", - "rector/rector": "^2.2.7", + "php": ">=8.3", + "rector/rector": "^2.6.4", + "symplify/rule-doc-generator-contracts": "^11.2", "webmozart/assert": "^1.11 || ^2.0" }, + "require-dev": { + "boundwize/structarmed": "^0.16", + "nette/utils": "^4.0", + "nikic/php-parser": "^5.6", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "^2.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-webmozart-assert": "^2.0", + "phpunit/phpunit": "^10.5", + "symplify/rule-doc-generator": "^12.2", + "tightenco/duster": "^3.1", + "tracy/tracy": "^2.10" + }, "type": "rector-extension", + "extra": { + "rector": { + "includes": [ + "config/config.php" + ] + } + }, "autoload": { "psr-4": { "RectorLaravel\\": "src" @@ -14153,9 +14361,9 @@ "description": "Rector upgrades rules for Laravel Framework", "support": { "issues": "https://github.com/driftingly/rector-laravel/issues", - "source": "https://github.com/driftingly/rector-laravel/tree/2.5.0" + "source": "https://github.com/driftingly/rector-laravel/tree/2.6.2" }, - "time": "2026-06-02T16:22:14+00:00" + "time": "2026-08-29T21:42:57+00:00" }, { "name": "fakerphp/faker", @@ -14222,23 +14430,23 @@ }, { "name": "fidry/cpu-core-counter", - "version": "1.3.0", + "version": "1.4.1", "source": { "type": "git", "url": "https://github.com/theofidry/cpu-core-counter.git", - "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + "reference": "d384a7d0d6e67aa497327260ca95023e92dc2032" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", - "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/d384a7d0d6e67aa497327260ca95023e92dc2032", + "reference": "d384a7d0d6e67aa497327260ca95023e92dc2032", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "fidry/makefile": "^0.2.0", + "fidry/makefile": "^0.2.0 || ^1.1.2", "fidry/php-cs-fixer-config": "^1.1.2", "phpstan/extension-installer": "^1.2.0", "phpstan/phpstan": "^2.0", @@ -14246,6 +14454,7 @@ "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "symfony/polyfill-mbstring": "^1.33", "webmozarts/strict-phpunit": "^7.5" }, "type": "library", @@ -14271,7 +14480,7 @@ ], "support": { "issues": "https://github.com/theofidry/cpu-core-counter/issues", - "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.4.1" }, "funding": [ { @@ -14279,20 +14488,20 @@ "type": "github" } ], - "time": "2025-08-14T07:29:31+00:00" + "time": "2026-09-29T22:11:39+00:00" }, { "name": "filp/whoops", - "version": "2.18.4", + "version": "2.18.5", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + "reference": "baec851ad6ae00db8bd0fdd30d208afc1b71c56e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "url": "https://api.github.com/repos/filp/whoops/zipball/baec851ad6ae00db8bd0fdd30d208afc1b71c56e", + "reference": "baec851ad6ae00db8bd0fdd30d208afc1b71c56e", "shasum": "" }, "require": { @@ -14301,8 +14510,8 @@ }, "require-dev": { "mockery/mockery": "^1.0", - "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", - "symfony/var-dumper": "^4.0 || ^5.0" + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3 || ^10.5.58", + "symfony/var-dumper": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0" }, "suggest": { "symfony/var-dumper": "Pretty print complex values better with var-dumper available", @@ -14342,7 +14551,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.18.4" + "source": "https://github.com/filp/whoops/tree/2.18.5" }, "funding": [ { @@ -14350,23 +14559,25 @@ "type": "github" } ], - "time": "2025-08-08T12:00:00+00:00" + "time": "2026-09-18T12:00:00+00:00" }, { "name": "hamcrest/hamcrest-php", - "version": "v2.1.1", + "version": "v3.0.0", "source": { "type": "git", "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", - "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52", "shasum": "" }, "require": { + "ext-ctype": "*", + "ext-dom": "*", "php": "^7.4|^8.0" }, "replace": { @@ -14375,13 +14586,15 @@ "kodova/hamcrest-php": "*" }, "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -14399,9 +14612,9 @@ ], "support": { "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0" }, - "time": "2025-04-30T06:54:44+00:00" + "time": "2026-03-17T11:56:53+00:00" }, { "name": "kelunik/certificate", @@ -14529,16 +14742,16 @@ }, { "name": "laravel/pint", - "version": "v1.30.4", + "version": "v1.32.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "a96cb6eee2961905d2fce7207aefb80945bf6b28" + "reference": "006bd7825eb01daf92959d060b89cd9514fe4253" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/a96cb6eee2961905d2fce7207aefb80945bf6b28", - "reference": "a96cb6eee2961905d2fce7207aefb80945bf6b28", + "url": "https://api.github.com/repos/laravel/pint/zipball/006bd7825eb01daf92959d060b89cd9514fe4253", + "reference": "006bd7825eb01daf92959d060b89cd9514fe4253", "shasum": "" }, "require": { @@ -14546,19 +14759,19 @@ "ext-mbstring": "*", "ext-tokenizer": "*", "ext-xml": "*", - "php": "^8.2.0" + "php": "^8.3.0" }, "require-dev": { "composer/semver": "^3.4.4", - "friendsofphp/php-cs-fixer": "^3.95.18", - "illuminate/view": "^12.65.0", - "larastan/larastan": "^3.10.0", - "laravel-zero/framework": "^12.1.0", + "friendsofphp/php-cs-fixer": "^3.95.25", + "illuminate/view": "^13.31.0", + "larastan/larastan": "^3.11.0", + "laravel-zero/framework": "^13.0.0", "laravel/agent-detector": "^2.0.2", - "laravel/prompts": "^0.3.22", - "mockery/mockery": "^1.6.12", + "laravel/prompts": "^0.3.24", + "mockery/mockery": "^1.6.15", "nunomaduro/termwind": "^2.4.0", - "pestphp/pest": "^3.8.7" + "pestphp/pest": "^4.7.8" }, "bin": [ "builds/pint" @@ -14595,7 +14808,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-08-05T16:47:22+00:00" + "time": "2026-09-10T10:47:55+00:00" }, { "name": "laravel/roster", @@ -14659,6 +14872,82 @@ }, "time": "2026-07-18T17:53:15+00:00" }, + { + "name": "laravel/vet", + "version": "v0.2.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/vet.git", + "reference": "7b10fd2beecf9f386c92731729c9f33a152b213b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/vet/zipball/7b10fd2beecf9f386c92731729c9f33a152b213b", + "reference": "7b10fd2beecf9f386c92731729c9f33a152b213b", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "ext-dom": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-phar": "*", + "ext-tokenizer": "*", + "ext-zip": "*", + "php": "^8.4" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.15.5|^8.0", + "laravel-zero/foundation": "^13.25", + "laravel-zero/framework": "^13.0", + "laravel/pint": "^1.32.1", + "mockery/mockery": "^1.6.15", + "pestphp/pest": "^5.2.1", + "pestphp/pest-plugin-phpstan": "^5.2.1", + "pestphp/pest-plugin-rector": "^5.0.4", + "phpstan/phpstan": "^2.2.16", + "rector/rector": "^2.6.7" + }, + "bin": [ + "builds/vet" + ], + "type": "composer-plugin", + "extra": { + "class": "Laravel\\Vet\\Composer\\Plugin" + }, + "autoload": { + "psr-4": { + "App\\": "app/", + "Laravel\\Vet\\Composer\\": "src/Composer/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "A dependency audit trust file for PHP.", + "keywords": [ + "Audit", + "cli", + "composer", + "console", + "dev", + "php", + "security", + "supply chain" + ], + "support": { + "issues": "https://github.com/laravel/vet/issues", + "source": "https://github.com/laravel/vet/tree/v0.2.1" + }, + "time": "2026-09-27T14:39:48+00:00" + }, { "name": "league/uri-components", "version": "7.8.1", @@ -14745,29 +15034,28 @@ }, { "name": "mockery/mockery", - "version": "1.6.12", + "version": "1.6.15", "source": { "type": "git", "url": "https://github.com/mockery/mockery.git", - "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", - "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "url": "https://api.github.com/repos/mockery/mockery/zipball/967a801bd188989a5669bd280f252d51c0fdc9ee", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee", "shasum": "" }, "require": { - "hamcrest/hamcrest-php": "^2.0.1", - "lib-pcre": ">=7.0", + "hamcrest/hamcrest-php": "^2.0 || ^3.0", "php": ">=7.3" }, "conflict": { "phpunit/phpunit": "<8.0" }, "require-dev": { - "phpunit/phpunit": "^8.5 || ^9.6.17", - "symplify/easy-coding-standard": "^12.1.14" + "phpunit/phpunit": "^9.6.36", + "symplify/easy-coding-standard": "^13.2.17" }, "type": "library", "autoload": { @@ -14824,24 +15112,24 @@ "security": "https://github.com/mockery/mockery/security/advisories", "source": "https://github.com/mockery/mockery" }, - "time": "2024-05-16T03:13:13+00:00" + "time": "2026-08-19T19:37:52+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.13.4", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -14876,15 +15164,15 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2025-08-01T08:46:24+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "nunomaduro/collision", @@ -15575,11 +15863,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.8", + "version": "2.2.16", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e285254e60f33c21902efef4a926ca0987c06804", - "reference": "e285254e60f33c21902efef4a926ca0987c06804", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", + "reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", "shasum": "" }, "require": { @@ -15635,7 +15923,7 @@ "type": "github" } ], - "time": "2026-08-04T22:21:45+00:00" + "time": "2026-09-25T09:31:51+00:00" }, { "name": "phpunit/php-code-coverage", @@ -15727,23 +16015,23 @@ }, { "name": "phpunit/php-file-iterator", - "version": "6.0.1", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", - "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.33" }, "type": "library", "extra": { @@ -15776,7 +16064,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2" }, "funding": [ { @@ -15796,7 +16084,7 @@ "type": "tidelift" } ], - "time": "2026-02-02T14:04:18+00:00" + "time": "2026-08-25T14:40:53+00:00" }, { "name": "phpunit/php-invoker", @@ -16074,21 +16362,21 @@ }, { "name": "rector/rector", - "version": "2.6.1", + "version": "2.6.7", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "b8e68f058bca43e01a2e1caa51ef022d6551ed95" + "reference": "f4aa224edb9b0630fb43826d4cb6a49b62e48d4c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/b8e68f058bca43e01a2e1caa51ef022d6551ed95", - "reference": "b8e68f058bca43e01a2e1caa51ef022d6551ed95", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/f4aa224edb9b0630fb43826d4cb6a49b62e48d4c", + "reference": "f4aa224edb9b0630fb43826d4cb6a49b62e48d4c", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.2.6" + "phpstan/phpstan": "^2.2.14" }, "conflict": { "rector/rector-doctrine": "*", @@ -16096,9 +16384,6 @@ "rector/rector-phpunit": "*", "rector/rector-symfony": "*" }, - "suggest": { - "ext-dom": "To manipulate phpunit.xml via the custom-rule command" - }, "bin": [ "bin/rector" ], @@ -16122,7 +16407,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.6.1" + "source": "https://github.com/rectorphp/rector/tree/2.6.7" }, "funding": [ { @@ -16130,7 +16415,7 @@ "type": "github" } ], - "time": "2026-08-03T17:30:34+00:00" + "time": "2026-09-13T20:17:44+00:00" }, { "name": "revolt/event-loop", @@ -16425,24 +16710,24 @@ }, { "name": "sebastian/diff", - "version": "7.0.0", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0", - "symfony/process": "^7.2" + "phpunit/phpunit": "^12.5.33", + "symfony/process": "^7.4.17" }, "type": "library", "extra": { @@ -16480,15 +16765,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" } ], - "time": "2025-02-07T04:55:46+00:00" + "time": "2026-08-25T15:35:54+00:00" }, { "name": "sebastian/environment", @@ -17115,16 +17412,16 @@ }, { "name": "serversideup/spin", - "version": "v3.3.0", + "version": "v3.5.2", "source": { "type": "git", "url": "https://github.com/serversideup/spin.git", - "reference": "4c65c3244470e2da44b0331efbefef5506a20fb2" + "reference": "faf54321caa9e16e4af172f467958c40cb5dd8c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/serversideup/spin/zipball/4c65c3244470e2da44b0331efbefef5506a20fb2", - "reference": "4c65c3244470e2da44b0331efbefef5506a20fb2", + "url": "https://api.github.com/repos/serversideup/spin/zipball/faf54321caa9e16e4af172f467958c40cb5dd8c3", + "reference": "faf54321caa9e16e4af172f467958c40cb5dd8c3", "shasum": "" }, "bin": [ @@ -17149,7 +17446,7 @@ "description": "Replicate your production environment locally using Docker. Just run \"spin up\". It's really that easy.", "support": { "issues": "https://github.com/serversideup/spin/issues", - "source": "https://github.com/serversideup/spin/tree/v3.3.0" + "source": "https://github.com/serversideup/spin/tree/v3.5.2" }, "funding": [ { @@ -17157,7 +17454,7 @@ "type": "github" } ], - "time": "2026-07-16T20:06:00+00:00" + "time": "2026-09-29T13:55:02+00:00" }, { "name": "staabm/side-effects-detector", @@ -17213,31 +17510,28 @@ }, { "name": "symfony/http-client", - "version": "v7.4.16", + "version": "v8.1.8", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "c513ed0ba5d1784a6b55fc84190dbe4451b12f41" + "reference": "e20aca4ceaa888197de5c792ffc551d99e43d98a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/c513ed0ba5d1784a6b55fc84190dbe4451b12f41", - "reference": "c513ed0ba5d1784a6b55fc84190dbe4451b12f41", + "url": "https://api.github.com/repos/symfony/http-client/zipball/e20aca4ceaa888197de5c792ffc551d99e43d98a", + "reference": "e20aca4ceaa888197de5c792ffc551d99e43d98a", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-client-contracts": "~3.4.4|^3.5.2", - "symfony/polyfill-php83": "^1.29", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/http-client-contracts": "^3.7", "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "amphp/amp": "<2.5", - "amphp/socket": "<1.1", - "php-http/discovery": "<1.15", - "symfony/http-foundation": "<6.4" + "amphp/amp": "<3", + "php-http/discovery": "<1.15" }, "provide": { "php-http/async-client-implementation": "*", @@ -17246,20 +17540,19 @@ "symfony/http-client-implementation": "3.0" }, "require-dev": { - "amphp/http-client": "^4.2.1|^5.0", - "amphp/http-tunnel": "^1.0|^2.0", - "guzzlehttp/promises": "^1.4|^2.0", + "amphp/http-client": "^5.3.2", + "amphp/http-tunnel": "^2.0", + "guzzlehttp/guzzle": "^7.10|^8.0", "nyholm/psr7": "^1.0", "php-http/httplug": "^1.0|^2.0", "psr/http-client": "^1.0", - "symfony/amphp-http-client-meta": "^1.0|^2.0", - "symfony/cache": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -17290,7 +17583,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.16" + "source": "https://github.com/symfony/http-client/tree/v8.1.8" }, "funding": [ { @@ -17310,20 +17603,20 @@ "type": "tidelift" } ], - "time": "2026-07-29T16:20:51+00:00" + "time": "2026-09-24T11:09:32+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f", "shasum": "" }, "require": { @@ -17372,7 +17665,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3" }, "funding": [ { @@ -17392,7 +17685,67 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-08-04T08:41:16+00:00" + }, + { + "name": "symplify/rule-doc-generator-contracts", + "version": "11.2.0", + "source": { + "type": "git", + "url": "https://github.com/symplify/rule-doc-generator-contracts.git", + "reference": "479cfcfd46047f80624aba931d9789e50475b5c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symplify/rule-doc-generator-contracts/zipball/479cfcfd46047f80624aba931d9789e50475b5c6", + "reference": "479cfcfd46047f80624aba931d9789e50475b5c6", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.2", + "rector/rector": "^0.15.10", + "symplify/easy-ci": "^11.1", + "symplify/easy-coding-standard": "^11.1", + "symplify/easy-testing": "^11.1", + "symplify/phpstan-extensions": "^11.1", + "symplify/phpstan-rules": "11.2.3.72", + "tomasvotruba/unused-public": "^0.0.34" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.2-dev" + } + }, + "autoload": { + "psr-4": { + "Symplify\\RuleDocGenerator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Contracts for production code of RuleDocGenerator", + "support": { + "source": "https://github.com/symplify/rule-doc-generator-contracts/tree/11.2.0" + }, + "funding": [ + { + "url": "https://www.paypal.me/rectorphp", + "type": "custom" + }, + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "abandoned": true, + "time": "2024-03-18T22:02:54+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", diff --git a/config/constants.php b/config/constants.php index 894e2d4db6..28b36469e2 100644 --- a/config/constants.php +++ b/config/constants.php @@ -6,8 +6,8 @@ $sshCommandTimeout = (int) env('SSH_COMMAND_TIMEOUT', 3600); return [ 'coolify' => [ 'version' => env('COOLIFY_VERSION') ?: '4.4.3', - 'helper_version' => '1.0.17', - 'railpack_version' => '0.23.0', + 'helper_version' => '1.0.18', + 'railpack_version' => '0.40.1', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), diff --git a/database/factories/ApplicationFactory.php b/database/factories/ApplicationFactory.php index 2ffc9f07ce..8e797f0fa9 100644 --- a/database/factories/ApplicationFactory.php +++ b/database/factories/ApplicationFactory.php @@ -21,6 +21,9 @@ class ApplicationFactory extends Factory 'ports_exposes' => '3000', 'environment_id' => 1, 'destination_id' => 1, + 'http_basic_auth_bcrypt_cost' => 10, + 'http_basic_auth_argon2id_memory_cost' => 65536, + 'http_basic_auth_argon2id_time_cost' => 4, ]; } } diff --git a/database/migrations/2026_07_10_210239_add_hostinger_virtual_machine_fields_to_servers_table.php b/database/migrations/2026_07_10_210239_add_hostinger_virtual_machine_fields_to_servers_table.php new file mode 100644 index 0000000000..496b2a3bce --- /dev/null +++ b/database/migrations/2026_07_10_210239_add_hostinger_virtual_machine_fields_to_servers_table.php @@ -0,0 +1,32 @@ +bigInteger('hostinger_virtual_machine_id')->nullable()->after('digitalocean_droplet_status'); + $table->string('hostinger_virtual_machine_status')->nullable()->after('hostinger_virtual_machine_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn([ + 'hostinger_virtual_machine_status', + 'hostinger_virtual_machine_id', + ]); + }); + } +}; diff --git a/database/migrations/2026_10_01_132654_add_proxy_error_page_to_servers_table.php b/database/migrations/2026_10_01_132654_add_proxy_error_page_to_servers_table.php new file mode 100644 index 0000000000..ad1a318250 --- /dev/null +++ b/database/migrations/2026_10_01_132654_add_proxy_error_page_to_servers_table.php @@ -0,0 +1,25 @@ +text('proxy_error_page')->nullable(); + }); + } + + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('proxy_error_page'); + }); + } +}; diff --git a/database/migrations/2026_10_01_161644_add_maintenance_mode_to_applications_and_services_tables.php b/database/migrations/2026_10_01_161644_add_maintenance_mode_to_applications_and_services_tables.php new file mode 100644 index 0000000000..cdafafc9e3 --- /dev/null +++ b/database/migrations/2026_10_01_161644_add_maintenance_mode_to_applications_and_services_tables.php @@ -0,0 +1,30 @@ +boolean('is_maintenance_enabled')->default(false); + $table->text('maintenance_page')->nullable(); + }); + } + } + + public function down(): void + { + foreach (['applications', 'services'] as $table) { + Schema::table($table, function (Blueprint $table) { + $table->dropColumn(['is_maintenance_enabled', 'maintenance_page']); + }); + } + } +}; diff --git a/database/migrations/2026_10_02_170101_add_http_basic_auth_hash_options_to_applications_table.php b/database/migrations/2026_10_02_170101_add_http_basic_auth_hash_options_to_applications_table.php new file mode 100644 index 0000000000..17212a63a6 --- /dev/null +++ b/database/migrations/2026_10_02_170101_add_http_basic_auth_hash_options_to_applications_table.php @@ -0,0 +1,30 @@ +string('http_basic_auth_hash_algorithm')->default('bcrypt'); + $table->unsignedTinyInteger('http_basic_auth_bcrypt_cost')->default(10); + $table->unsignedInteger('http_basic_auth_argon2id_memory_cost')->default(65536); + $table->unsignedTinyInteger('http_basic_auth_argon2id_time_cost')->default(4); + }); + } + + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn([ + 'http_basic_auth_hash_algorithm', + 'http_basic_auth_bcrypt_cost', + 'http_basic_auth_argon2id_memory_cost', + 'http_basic_auth_argon2id_time_cost', + ]); + }); + } +}; diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 1fe99f01c2..b3c068f437 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -12,9 +12,9 @@ ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases ARG NIXPACKS_VERSION=1.41.0 # https://github.com/railwayapp/railpack/releases -ARG RAILPACK_VERSION=0.23.0 -# https://github.com/jdx/mise/releases — must match railpack's pinned version (https://raw.githubusercontent.com/railwayapp/railpack/refs/heads/main/core/mise/version.txt) -ARG MISE_VERSION=2026.3.17 +ARG RAILPACK_VERSION=0.40.1 +# https://github.com/jdx/mise/releases — must match the version pinned at that railpack tag (https://raw.githubusercontent.com/railwayapp/railpack/v${RAILPACK_VERSION}/core/mise/version.txt) +ARG MISE_VERSION=2026.9.15 FROM quay.io/minio/aistor/mc:RELEASE.2026-09-06T02-44-40Z AS minio-client FROM ${BASE_IMAGE} AS base @@ -36,7 +36,7 @@ RUN apk upgrade --no-cache && \ RUN mkdir -p ~/.docker/cli-plugins # Install mise (musl build) at the path railpack expects (/tmp/railpack/mise/mise-VERSION). -# Railpack hardcodes a glibc mise download that fails on Alpine, so we pre-place a musl binary. +# Railpack skips its own mise download when this file exists. RUN mkdir -p /tmp/railpack/mise && \ if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \ curl -sSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64-musl.tar.gz" | tar xz && \ diff --git a/openapi.json b/openapi.json index 99587429fa..192d6f49b9 100644 --- a/openapi.json +++ b/openapi.json @@ -980,6 +980,36 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "default": "bcrypt", + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "default": 10, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "default": 65536, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 4, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -1571,6 +1601,36 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "default": "bcrypt", + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "default": 10, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "default": 65536, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 4, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -2162,6 +2222,36 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "default": "bcrypt", + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "default": 10, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "default": 65536, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 4, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -2648,6 +2738,36 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "default": "bcrypt", + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "default": 10, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "default": 65536, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 4, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -3114,6 +3234,36 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "default": "bcrypt", + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "default": 10, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "default": 65536, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 4, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -3703,6 +3853,32 @@ "type": "boolean", "description": "Use Docker Build Secrets for build-time environment variables." }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "is_git_submodules_enabled": { "type": "boolean", "description": "Clone Git submodules." @@ -6142,7 +6318,8 @@ "enum": [ "hetzner", "digitalocean", - "vultr" + "vultr", + "hostinger" ] }, "team_id": { @@ -6201,7 +6378,8 @@ "enum": [ "hetzner", "digitalocean", - "vultr" + "vultr", + "hostinger" ], "example": "hetzner", "description": "The cloud provider." @@ -12697,6 +12875,189 @@ ] } }, + "\/hostinger\/data-centers": { + "get": { + "tags": [ + "Hostinger" + ], + "summary": "Get Hostinger VPS data centers", + "operationId": "get-hostinger-data-centers", + "responses": { + "200": { + "description": "List of Hostinger VPS data centers." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Hostinger denied access to this resource for the cloud provider token." + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hostinger\/catalog": { + "get": { + "tags": [ + "Hostinger" + ], + "summary": "Get Hostinger VPS plans and prices", + "operationId": "get-hostinger-catalog", + "responses": { + "200": { + "description": "List of Hostinger VPS plans and prices." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Hostinger denied access to this resource for the cloud provider token." + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hostinger\/templates": { + "get": { + "tags": [ + "Hostinger" + ], + "summary": "Get Hostinger VPS operating system templates", + "operationId": "get-hostinger-templates", + "responses": { + "200": { + "description": "List of Hostinger VPS templates." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Hostinger denied access to this resource for the cloud provider token." + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hostinger\/ssh-keys": { + "get": { + "tags": [ + "Hostinger" + ], + "summary": "Get Hostinger account SSH keys", + "operationId": "get-hostinger-ssh-keys", + "responses": { + "200": { + "description": "List of Hostinger account SSH keys." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Hostinger denied access to this resource for the cloud provider token." + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hostinger\/post-install-scripts": { + "get": { + "tags": [ + "Hostinger" + ], + "summary": "Get Hostinger post-install scripts", + "operationId": "get-hostinger-post-install-scripts", + "responses": { + "200": { + "description": "List of Hostinger post-install scripts." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Hostinger denied access to this resource for the cloud provider token." + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/hostinger": { + "post": { + "tags": [ + "Hostinger" + ], + "summary": "Purchase a Hostinger VPS and create a linked server", + "operationId": "create-hostinger-server", + "responses": { + "201": { + "description": "Hostinger VPS purchased and linked to a Coolify server." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/settings\/email": { "get": { "tags": [ @@ -23753,6 +24114,32 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "settings": { "$ref": "#\/components\/schemas\/ApplicationSetting" } @@ -24875,6 +25262,10 @@ "name": "Hetzner", "description": "Hetzner" }, + { + "name": "Hostinger", + "description": "Hostinger" + }, { "name": "Settings", "description": "Settings" diff --git a/openapi.yaml b/openapi.yaml index e298549e80..5983cbe38d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -646,6 +646,29 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + default: bcrypt + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + default: 10 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + default: 65536 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + default: 4 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -1032,6 +1055,29 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + default: bcrypt + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + default: 10 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + default: 65536 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + default: 4 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -1418,6 +1464,29 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + default: bcrypt + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + default: 10 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + default: 65536 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + default: 4 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -1745,6 +1814,29 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + default: bcrypt + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + default: 10 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + default: 65536 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + default: 4 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -2058,6 +2150,29 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + default: bcrypt + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + default: 10 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + default: 65536 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + default: 4 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -2430,6 +2545,25 @@ paths: use_build_secrets: type: boolean description: 'Use Docker Build Secrets for build-time environment variables.' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' is_git_submodules_enabled: type: boolean description: 'Clone Git submodules.' @@ -3941,7 +4075,7 @@ paths: schema: type: array items: - properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean, vultr] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } } + properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean, vultr, hostinger] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } } type: object '401': $ref: '#/components/responses/401' @@ -3969,7 +4103,7 @@ paths: properties: provider: type: string - enum: [hetzner, digitalocean, vultr] + enum: [hetzner, digitalocean, vultr, hostinger] example: hetzner description: 'The cloud provider.' token: @@ -8113,6 +8247,124 @@ paths: security: - bearerAuth: [] + /hostinger/data-centers: + get: + tags: + - Hostinger + summary: 'Get Hostinger VPS data centers' + operationId: get-hostinger-data-centers + responses: + '200': + description: 'List of Hostinger VPS data centers.' + '401': + $ref: '#/components/responses/401' + '403': + description: 'Hostinger denied access to this resource for the cloud provider token.' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] + /hostinger/catalog: + get: + tags: + - Hostinger + summary: 'Get Hostinger VPS plans and prices' + operationId: get-hostinger-catalog + responses: + '200': + description: 'List of Hostinger VPS plans and prices.' + '401': + $ref: '#/components/responses/401' + '403': + description: 'Hostinger denied access to this resource for the cloud provider token.' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] + /hostinger/templates: + get: + tags: + - Hostinger + summary: 'Get Hostinger VPS operating system templates' + operationId: get-hostinger-templates + responses: + '200': + description: 'List of Hostinger VPS templates.' + '401': + $ref: '#/components/responses/401' + '403': + description: 'Hostinger denied access to this resource for the cloud provider token.' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] + /hostinger/ssh-keys: + get: + tags: + - Hostinger + summary: 'Get Hostinger account SSH keys' + operationId: get-hostinger-ssh-keys + responses: + '200': + description: 'List of Hostinger account SSH keys.' + '401': + $ref: '#/components/responses/401' + '403': + description: 'Hostinger denied access to this resource for the cloud provider token.' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] + /hostinger/post-install-scripts: + get: + tags: + - Hostinger + summary: 'Get Hostinger post-install scripts' + operationId: get-hostinger-post-install-scripts + responses: + '200': + description: 'List of Hostinger post-install scripts.' + '401': + $ref: '#/components/responses/401' + '403': + description: 'Hostinger denied access to this resource for the cloud provider token.' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] + /servers/hostinger: + post: + tags: + - Hostinger + summary: 'Purchase a Hostinger VPS and create a linked server' + operationId: create-hostinger-server + responses: + '201': + description: 'Hostinger VPS purchased and linked to a Coolify server.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] /settings/email: get: tags: @@ -15139,6 +15391,27 @@ components: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: + - bcrypt + - argon2id + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' settings: $ref: '#/components/schemas/ApplicationSetting' type: object @@ -15923,6 +16196,9 @@ tags: - name: Hetzner description: Hetzner + - + name: Hostinger + description: Hostinger - name: Settings description: Settings diff --git a/other/nightly/versions.json b/other/nightly/versions.json index bb76d98724..40f297c2a1 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -7,7 +7,7 @@ "version": "4.5-rc.1" }, "helper": { - "version": "1.0.17" + "version": "1.0.18" }, "sentinel": { "version": "1.0.2" diff --git a/resources/views/components/hostinger-icon.blade.php b/resources/views/components/hostinger-icon.blade.php new file mode 100644 index 0000000000..9616c8d965 --- /dev/null +++ b/resources/views/components/hostinger-icon.blade.php @@ -0,0 +1,7 @@ + + Hostinger + + diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 5380e6f647..de9f9bf08d 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -41,7 +41,7 @@ 'active' => $activeMenu === 'cloud-provider-token', 'icon' => 'subscription', 'group' => 'Settings', - 'visible' => (bool) ($server->hetzner_server_id || $server->vultr_instance_id), + 'visible' => (bool) ($server->hetzner_server_id || $server->vultr_instance_id || $server->digitalocean_droplet_id || $server->hostinger_virtual_machine_id), ], [ 'label' => 'CA Certificate', diff --git a/resources/views/livewire/boarding/index.blade.php b/resources/views/livewire/boarding/index.blade.php index bc22715931..71c065dac3 100644 --- a/resources/views/livewire/boarding/index.blade.php +++ b/resources/views/livewire/boarding/index.blade.php @@ -207,6 +207,23 @@ + + +
+
+ +
+

Hostinger

+

+ Deploy servers directly from your Hostinger account. +

+
+
+
+
+ +
@endif diff --git a/resources/views/livewire/project/application/configuration.blade.php b/resources/views/livewire/project/application/configuration.blade.php index 11d6400bf6..168b102e0c 100644 --- a/resources/views/livewire/project/application/configuration.blade.php +++ b/resources/views/livewire/project/application/configuration.blade.php @@ -14,6 +14,7 @@ @elseif ($currentRoute === 'project.application.domains') + @elseif ($currentRoute === 'project.application.swarm' && $application->destination->server->isSwarm()) @elseif ($currentRoute === 'project.application.advanced') diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 3f8ef0fce9..fa986e8532 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -1,3 +1,4 @@ +@php use App\Enums\HttpBasicAuthHashAlgorithm; use App\Enums\ProxyTypes; @endphp
+ @php + $server = $application->destination->server; + $supportsArgon2id = $server->caddySupportsArgon2idBasicAuth(); + $usesArgon2id = $httpBasicAuthHashAlgorithm === HttpBasicAuthHashAlgorithm::ARGON2ID->value; + @endphp +
+

Password hashing

+
+ + @if ($usesArgon2id) +
+ +
+
+ +
+ @else +
+ +
+ @endif +
+
@endif @endif diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php index 379c6f9f4f..0cc231603a 100644 --- a/resources/views/livewire/project/service/configuration.blade.php +++ b/resources/views/livewire/project/service/configuration.blade.php @@ -197,6 +197,7 @@ :lazy="(bool) ($service->server ?? $service->destination?->server)?->isTrafficAnalyticsEnabled()" /> @elseif ($currentRoute === 'project.service.domains') + @elseif ($currentRoute === 'project.service.environment-variables') diff --git a/resources/views/livewire/project/shared/danger.blade.php b/resources/views/livewire/project/shared/danger.blade.php index faeff1aed2..5aaf304ef1 100644 --- a/resources/views/livewire/project/shared/danger.blade.php +++ b/resources/views/livewire/project/shared/danger.blade.php @@ -17,6 +17,23 @@ @endif + @if (in_array($resource?->type(), ['standalone-redis', 'standalone-keydb', 'standalone-dragonfly'])) + +

+ Permanently remove every key from + {{ $resourceName }}. + The database keeps running. +

+ + + +
+ @endif

Permanently delete diff --git a/resources/views/livewire/project/shared/maintenance.blade.php b/resources/views/livewire/project/shared/maintenance.blade.php new file mode 100644 index 0000000000..822d672f47 --- /dev/null +++ b/resources/views/livewire/project/shared/maintenance.blade.php @@ -0,0 +1,30 @@ +

+ + + @if ($this->isSupported) +
+
+ +
+ + @can('update', $resource) +
+ Reset to + default +
+ @endcan +
+ @else +

Maintenance mode needs a Traefik or Caddy proxy on a + server that is not part of a Docker Swarm.

+ @endif +
+ diff --git a/resources/views/livewire/security/cloud-provider-token-form.blade.php b/resources/views/livewire/security/cloud-provider-token-form.blade.php index 271ad7367d..d6f14e188d 100644 --- a/resources/views/livewire/security/cloud-provider-token-form.blade.php +++ b/resources/views/livewire/security/cloud-provider-token-form.blade.php @@ -9,6 +9,7 @@ get providerConsoleUrl() { if (this.selectedProvider === 'hetzner') return 'https://console.hetzner.com/projects'; if (this.selectedProvider === 'vultr') return 'https://console.vultr.com/user/apiaccess/'; + if (this.selectedProvider === 'hostinger') return 'https://hpanel.hostinger.com/profile/api'; return 'https://cloud.digitalocean.com/account/api/tokens'; } }"> @@ -18,6 +19,7 @@ ['value' => 'hetzner', 'label' => 'Hetzner'], ['value' => 'digitalocean', 'label' => 'DigitalOcean'], ['value' => 'vultr', 'label' => 'Vultr'], + ['value' => 'hostinger', 'label' => 'Hostinger'], ]" /> @else diff --git a/resources/views/livewire/server/cloud-provider-token/show.blade.php b/resources/views/livewire/server/cloud-provider-token/show.blade.php index 5c4fe5d98f..e4fe7c19d1 100644 --- a/resources/views/livewire/server/cloud-provider-token/show.blade.php +++ b/resources/views/livewire/server/cloud-provider-token/show.blade.php @@ -10,7 +10,7 @@
- @if ($server->hetzner_server_id || $server->vultr_instance_id) + @if ($server->hetzner_server_id || $server->vultr_instance_id || $server->digitalocean_droplet_id || $server->hostinger_virtual_machine_id) @@ -67,7 +67,7 @@ @endif diff --git a/resources/views/livewire/server/create.blade.php b/resources/views/livewire/server/create.blade.php index d1530985e5..3bed1f90d0 100644 --- a/resources/views/livewire/server/create.blade.php +++ b/resources/views/livewire/server/create.blade.php @@ -50,7 +50,7 @@

Create a server with a cloud provider.

@@ -113,6 +127,10 @@ + @elseif ($selectedType === 'hostinger') + @else diff --git a/resources/views/livewire/server/delete.blade.php b/resources/views/livewire/server/delete.blade.php index fb5e9c3172..2e96838e63 100644 --- a/resources/views/livewire/server/delete.blade.php +++ b/resources/views/livewire/server/delete.blade.php @@ -21,12 +21,28 @@ It currently contains managed resources. Enable force deletion in the confirmation only if those resources should also be removed. @endif + @if ($server->hostinger_virtual_machine_id) + Hostinger deletes the VPS only at the end of its paid period, and only if you select this in the confirmation dialog. + Manage the VPS and billing in hPanel. + @endif

Type the server name in the confirmation dialog to continue.

+ @php + $deleteActions = ['This server will be permanently deleted from Coolify.']; + $deleteWarningMessage = null; + if ($server->hostinger_virtual_machine_id) { + $deleteWarningMessage = 'Hostinger deletes a VPS only at the end of its paid period.
' + .'If you select to delete the VPS from Hostinger, Coolify turns off its auto-renewal. ' + .'The VPS keeps running until the paid period ends. Then Hostinger deletes it with all its data. ' + .'To delete it now or to request a refund, ' + .'cancel the VPS in hPanel.'; + } + @endphp diff --git a/resources/views/livewire/server/new/by-hostinger.blade.php b/resources/views/livewire/server/new/by-hostinger.blade.php new file mode 100644 index 0000000000..34010ba5d9 --- /dev/null +++ b/resources/views/livewire/server/new/by-hostinger.blade.php @@ -0,0 +1,147 @@ +
+ @if ($limit_reached) + + @elseif ($current_step === 1) +
+ +

+ New to Hostinger? + Create an account + through Coolify's affiliate link. +

+
+ @elseif ($current_step === 2) +
+ @if ($loading_data) + +
+ +
+
+ @elseif ($provider_data_error) + + +
{{ $provider_data_error }}
+
+ +
+ @else + @php + $dataCenterOptions = collect($data_centers)->map(fn ($dataCenter) => [ + 'value' => $dataCenter['id'], + 'label' => ($dataCenter['city'] ?? $dataCenter['name'] ?? $dataCenter['id']) + . (!empty($dataCenter['location']) ? ' · ' . strtoupper($dataCenter['location']) : ''), + ])->values()->all(); + $priceOptions = collect($this->priceOptions)->map(fn ($price) => [ + 'value' => $price['id'], + 'label' => $this->priceLabel($price), + ])->values()->all(); + $templateOption = fn ($template) => [ + 'value' => $template['id'], + 'label' => $template['name'] ?? $template['description'] ?? $template['id'], + ]; + $osTemplateOptions = collect($this->osTemplates)->map($templateOption)->values()->all(); + $appTemplateOptions = collect($this->appTemplates)->map($templateOption)->values()->all(); + $privateKeyOptions = $private_keys->map(fn ($key) => [ + 'value' => $key->id, + 'label' => $key->name, + ])->values()->all(); + $postInstallScriptOptions = collect($post_install_scripts)->map(fn ($script) => [ + 'value' => $script['id'], + 'label' => $script['name'], + ])->values()->all(); + @endphp + +
+ + + + + +
+
+ +
+ + + + + @if ($private_keys->isEmpty()) +
+ +
+ A private key is required. + + + + + + +
+
+ @else + + @endif +
+
+ + +
+
+ +
+ + @foreach ($hostinger_public_keys as $publicKey) + + @endforeach + + @if ($post_install_scripts) + + @endif + + The VPS is purchased with your Hostinger account's default payment method. Review the + selected plan and billing period before continuing. + +
+
+
+ @endif +
+ @endif +
diff --git a/resources/views/livewire/server/proxy.blade.php b/resources/views/livewire/server/proxy.blade.php index 037466932a..0be3fbd86e 100644 --- a/resources/views/livewire/server/proxy.blade.php +++ b/resources/views/livewire/server/proxy.blade.php @@ -69,7 +69,7 @@ ['value' => true, 'label' => 'Labels for the active proxy only'], ]" /> + helper="Leave empty to return a 503 response instead of redirecting." /> @endif + @if ($redirectEnabled && ! $server->isSwarm()) +
+ + @can('update', $server) +
+ Reset to + default +
+ @endcan +
+ @endif
@php diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index acfd8cec6a..8d17abd5d8 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -1,5 +1,5 @@
+ x-init="@if ($server->hetzner_server_id && $server->cloudProviderToken && !$hetznerServerStatus) $wire.checkHetznerServerStatus(); @endif @if ($server->vultr_instance_id && $server->cloudProviderToken) $wire.checkVultrInstanceStatus(); @endif @if ($server->digitalocean_droplet_id && $server->cloudProviderToken && !$digitalOceanDropletStatus) $wire.checkDigitalOceanDropletStatus(); @endif @if ($server->hostinger_virtual_machine_id && $server->cloudProviderToken && !$hostingerVirtualMachineStatus) $wire.checkHostingerVirtualMachineStatus(); @endif"> {{ data_get_str($server, 'name')->limit(24) }} | Server | Coolify @@ -18,23 +18,26 @@ filled($server->hetzner_server_id) => 'Hetzner', filled($server->digitalocean_droplet_id) => 'DigitalOcean', filled($server->vultr_instance_id) => 'Vultr', + filled($server->hostinger_virtual_machine_id) => 'Hostinger', default => null, }; $providerStatus = match ($provider) { 'Hetzner' => $hetznerServerStatus, 'DigitalOcean' => $digitalOceanDropletStatus, 'Vultr' => $vultrInstanceStatus, + 'Hostinger' => $hostingerVirtualMachineStatus, default => null, }; $providerStatusType = match (true) { in_array($providerStatus, ['running', 'active']) => 'success', - in_array($providerStatus, ['starting', 'initializing', 'pending', 'new']) => 'warning', - in_array($providerStatus, ['off', 'stopped', 'suspended', 'archive', 'deleted']) => 'error', + in_array($providerStatus, ['starting', 'initializing', 'pending', 'new', 'initial', 'creating', 'unsuspending', 'recreating', 'restoring']) => 'warning', + in_array($providerStatus, ['off', 'stopped', 'suspended', 'archive', 'deleted', 'destroyed', 'error']) => 'error', default => 'neutral', }; $hasLinkableCloudProviders = (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty()) || (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty()) - || (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty()); + || (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty()) + || (!$server->hostinger_virtual_machine_id && $availableHostingerTokens->isNotEmpty()); $hetznerMatch = $matchedHetznerServer ? [ 'name' => $matchedHetznerServer['name'] ?? 'Hetzner server', @@ -56,6 +59,13 @@ 'status' => $matchedVultrInstance['status'] ?? null, ] : null; + $hostingerMatch = $matchedHostingerVirtualMachine + ? [ + 'name' => $matchedHostingerVirtualMachine['hostname'] ?? 'Hostinger VPS', + 'id' => $matchedHostingerVirtualMachine['id'] ?? null, + 'status' => $matchedHostingerVirtualMachine['state'] ?? null, + ] + : null; @endphp
@@ -107,6 +117,18 @@ :searchError="$vultrSearchError" :noMatch="$vultrNoMatchFound" :matched="$vultrMatch" /> @endif + @if (!$server->hostinger_virtual_machine_id && $availableHostingerTokens->isNotEmpty()) + + @endif
@endif @@ -272,6 +294,12 @@ title="Refresh provider status"> + @elseif ($provider === 'Hostinger') + + + @endif @if ($server->cloudProviderToken) @if ($provider === 'Hetzner' && !$server->isFunctional() && $hetznerServerStatus === 'off') diff --git a/resources/views/proxy/error-page.blade.php b/resources/views/proxy/error-page.blade.php new file mode 100644 index 0000000000..4a8d2e7cc1 --- /dev/null +++ b/resources/views/proxy/error-page.blade.php @@ -0,0 +1,18 @@ +@extends('proxy.layout') + +@section('title', 'Site not available') + +@section('content') +

503 Service Unavailable

+

This site is not available at the moment

+

This site cannot answer your request right now. Please try again later.

+
+

Are you the site owner?

+
    +
  • The domain is not configured on a resource in Coolify, or its DNS points to another server.
  • +
  • The application is stopped, still deploying, or has crashed.
  • +
  • The application health check is failing.
  • +
+ Read the troubleshooting guide +
+@endsection diff --git a/resources/views/proxy/layout.blade.php b/resources/views/proxy/layout.blade.php new file mode 100644 index 0000000000..077b3ce2f6 --- /dev/null +++ b/resources/views/proxy/layout.blade.php @@ -0,0 +1,116 @@ + + + + + + + @yield('title') + + + +
+ @yield('content') +
+ + + diff --git a/resources/views/proxy/maintenance-page.blade.php b/resources/views/proxy/maintenance-page.blade.php new file mode 100644 index 0000000000..236e6c8706 --- /dev/null +++ b/resources/views/proxy/maintenance-page.blade.php @@ -0,0 +1,9 @@ +@extends('proxy.layout') + +@section('title', 'Under maintenance') + +@section('content') +

503 Service Unavailable

+

We are doing some maintenance

+

This site is temporarily unavailable for maintenance. Please check back soon.

+@endsection diff --git a/routes/api.php b/routes/api.php index a44b95a1d2..54d500973b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,6 +13,7 @@ use App\Http\Controllers\Api\DigitalOceanController; use App\Http\Controllers\Api\GithubController; use App\Http\Controllers\Api\GitlabController; use App\Http\Controllers\Api\HetznerController; +use App\Http\Controllers\Api\HostingerController; use App\Http\Controllers\Api\InstanceEmailSettingsController; use App\Http\Controllers\Api\IntegrationTokensController; use App\Http\Controllers\Api\NotificationsController; @@ -232,6 +233,13 @@ Route::group([ Route::get('/digitalocean/ssh-keys', [DigitalOceanController::class, 'sshKeys'])->middleware(['api.ability:read']); Route::post('/servers/digitalocean', [DigitalOceanController::class, 'createServer'])->middleware(['api.ability:write']); + Route::get('/hostinger/data-centers', [HostingerController::class, 'dataCenters'])->middleware(['api.ability:read']); + Route::get('/hostinger/catalog', [HostingerController::class, 'catalog'])->middleware(['api.ability:read']); + Route::get('/hostinger/templates', [HostingerController::class, 'templates'])->middleware(['api.ability:read']); + Route::get('/hostinger/ssh-keys', [HostingerController::class, 'sshKeys'])->middleware(['api.ability:read']); + Route::get('/hostinger/post-install-scripts', [HostingerController::class, 'postInstallScripts'])->middleware(['api.ability:read']); + Route::post('/servers/hostinger', [HostingerController::class, 'createServer'])->middleware(['api.ability:write']); + Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']); Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']); diff --git a/routes/web.php b/routes/web.php index 65a9cb4d48..3294c6edf7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -377,8 +377,8 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/registries', ServerDockerRegistries::class)->name('registries.index'); Route::get('/servers/import', ServerTransferImport::class)->name('server.transfer.import')->middleware('can:create,'.Server::class); Route::get('/servers/new', ServerCreatePage::class)->name('server.create')->middleware('can:create,'.Server::class); - Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean']); - Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'manual']); + Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger']); + Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual']); Route::prefix('server/{server_uuid}')->group(function () { Route::get('/', ServerShow::class)->name('server.show'); diff --git a/scripts/dev b/scripts/dev index 5a02758a58..0da92917a2 100755 --- a/scripts/dev +++ b/scripts/dev @@ -382,9 +382,10 @@ start_backend() { local host="${DEV_BIND_ADDRESS:-127.0.0.1}" [[ "$host" == 0.0.0.0 ]] && host=127.0.0.1 echo "Using KVM profile: $profile" - # The host command must reach this instance's database and encrypt with this instance's - # APP_KEY, not the ones in .env, and must not create root-owned files in storage/logs. - LOG_CHANNEL=stderr APP_KEY="$(env_value "$instance" APP_KEY)" \ + # Use this instance database and encryption key, without a stale environment or config cache. + # Send logs to stderr to avoid root-owned files in storage/logs. + APP_ENV=local APP_CONFIG_CACHE="$ROOT/bootstrap/cache/dev-qemu-$$.php" \ + LOG_CHANNEL=stderr APP_KEY="$(env_value "$instance" APP_KEY)" \ DB_HOST="$host" DB_PORT="$(env_value "$instance" FORWARD_DB_PORT)" \ REDIS_HOST="$host" REDIS_PORT="$(env_value "$instance" FORWARD_REDIS_PORT)" \ DEVELOPMENT_QEMU_INSTANCE="$instance" \ diff --git a/tests/Feature/Api/ApplicationSettingsApiTest.php b/tests/Feature/Api/ApplicationSettingsApiTest.php index 71064711c5..14cf1e1c1a 100644 --- a/tests/Feature/Api/ApplicationSettingsApiTest.php +++ b/tests/Feature/Api/ApplicationSettingsApiTest.php @@ -295,6 +295,35 @@ test('deployment parsing keeps commas in raw custom label values', function () { ->and($this->application->fresh()->custom_labels)->toBe(base64_encode($labels)); }); +test('http basic auth hash settings are saved and applied to managed labels', function () { + $this->application->update([ + 'fqdn' => 'https://app.example.com', + 'is_http_basic_auth_enabled' => true, + 'http_basic_auth_username' => 'api-user', + 'http_basic_auth_password' => 'api-password', + ]); + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", ['http_basic_auth_bcrypt_cost' => 4]) + ->assertOk(); + + expect($this->application->fresh()->parseContainerLabels())->toContain('.basicauth.users=api-user:$2y$04$'); +}); + +test('rejects http basic auth settings that are not supported', function (string $field, mixed $value) { + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [$field => $value]) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); +})->with([ + 'Argon2id on a proxy without it' => ['http_basic_auth_hash_algorithm', 'argon2id'], + 'unknown algorithm' => ['http_basic_auth_hash_algorithm', 'md5'], + 'bcrypt cost above 14' => ['http_basic_auth_bcrypt_cost', 15], + 'Argon2id memory above 256 MiB' => ['http_basic_auth_argon2id_memory_cost', 262145], + 'Argon2id iterations above 12' => ['http_basic_auth_argon2id_time_cost', 13], + 'password longer than 72 characters' => ['http_basic_auth_password', str_repeat('a', 73)], +]); + test('rejects invalid boolean application settings', function () { $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) ->patchJson("/api/v1/applications/{$this->application->uuid}", [ diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index 076bbae527..6706bcda87 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -1372,7 +1372,7 @@ it('builds railpack variables from generic buildtime vars railpack vars and cool expect($variables->get('APP_ENV'))->toBe('production'); expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20'); expect($variables->get('RAILPACK_INSTALL_CMD'))->toBe('pnpm install --frozen-lockfile'); - expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget'); + expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... curl wget'); expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid); expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); expect($variables->has('RUNTIME_ONLY'))->toBeFalse(); @@ -1424,7 +1424,7 @@ it('builds preview railpack variables without leaking stale nixpacks vars', func expect($variables->get('PREVIEW_BUILD_FLAG'))->toBe('enabled'); expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20'); - expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget'); + expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... curl wget'); expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid); expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); expect($variables->has('PREVIEW_RUNTIME_ONLY'))->toBeFalse(); diff --git a/tests/Feature/ApplicationHttpBasicAuthUiTest.php b/tests/Feature/ApplicationHttpBasicAuthUiTest.php index 78d42bd481..666cddec44 100644 --- a/tests/Feature/ApplicationHttpBasicAuthUiTest.php +++ b/tests/Feature/ApplicationHttpBasicAuthUiTest.php @@ -1,5 +1,6 @@ team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); @@ -47,9 +49,12 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== ?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']); }); +afterEach(fn () => Server::flushIdentityMap()); + function createApplicationForHttpBasicAuth(array $overrides = []): Application { return Application::factory()->create(array_merge([ + 'name' => 'Basic Auth App', 'environment_id' => test()->environment->id, 'destination_id' => test()->destination->id, 'destination_type' => StandaloneDocker::class, @@ -112,3 +117,68 @@ test('disabling http basic auth is saved instantly', function () { expect((bool) $application->is_http_basic_auth_enabled)->toBeFalse(); }); + +function useCaddyProxyForHttpBasicAuth(string $image): void +{ + test()->server->forceFill(['proxy' => [ + 'type' => ProxyTypes::CADDY->value, + 'status' => 'running', + 'last_saved_settings' => 'applied', + 'last_applied_settings' => 'applied', + 'last_saved_proxy_configuration' => "services:\n caddy:\n image: '{$image}'\n", + ]])->save(); +} + +test('changing the bcrypt cost factor is saved instantly and applied to the proxy labels', function () { + $application = createApplicationForHttpBasicAuth(['fqdn' => 'https://app.example.com']); + + Livewire::test(General::class, ['application' => $application]) + ->set('httpBasicAuthBcryptCost', 4) + ->call('instantSave') + ->assertDispatched('success'); + + $application->refresh(); + + expect($application->http_basic_auth_bcrypt_cost)->toBe(4) + ->and(base64_decode($application->custom_labels))->toContain('.basicauth.users=admin:$2y$04$'); +}); + +test('selecting Argon2id is saved instantly and applied to the Caddy labels', function () { + useCaddyProxyForHttpBasicAuth('lucaslorentz/caddy-docker-proxy:2.13-alpine'); + $application = createApplicationForHttpBasicAuth(['fqdn' => 'https://app.example.com']); + + Livewire::test(General::class, ['application' => $application]) + ->set('httpBasicAuthHashAlgorithm', 'argon2id') + ->set('httpBasicAuthArgon2idMemoryCost', 8192) + ->call('instantSave') + ->assertDispatched('success') + ->assertSee('Iterations') + ->assertDontSee('Cost factor'); + + expect(base64_decode($application->refresh()->custom_labels)) + ->toContain('caddy_0.basic_auth=argon2id', 'caddy_0.basic_auth.admin="$argon2id$v=19$m=8192,t=4,p=1$'); +}); + +test('http basic auth form explains why Argon2id cannot be selected', function (?string $image, string $reason) { + if ($image !== null) { + useCaddyProxyForHttpBasicAuth($image); + } + + Livewire::test(General::class, ['application' => createApplicationForHttpBasicAuth()]) + ->assertSee($reason); +})->with([ + 'Traefik' => [null, 'Only supported by the Caddy proxy'], + 'Caddy 2.10' => ['lucaslorentz/caddy-docker-proxy:2.10-alpine', 'Needs Caddy 2.11 or newer on this server'], +]); + +test('an unsupported http basic auth setting is rejected', function (string $property, mixed $value) { + Livewire::test(General::class, ['application' => createApplicationForHttpBasicAuth()]) + ->set($property, $value) + ->call('instantSave') + ->assertDispatched('error') + ->assertNotDispatched('success'); +})->with([ + 'Argon2id on a proxy without it' => ['httpBasicAuthHashAlgorithm', 'argon2id'], + 'bcrypt cost above 14' => ['httpBasicAuthBcryptCost', 15], + 'password longer than 72 characters' => ['httpBasicAuthPassword', str_repeat('a', 73)], +]); diff --git a/tests/Feature/BoardingActionsLayoutTest.php b/tests/Feature/BoardingActionsLayoutTest.php new file mode 100644 index 0000000000..71e7b824e4 --- /dev/null +++ b/tests/Feature/BoardingActionsLayoutTest.php @@ -0,0 +1,84 @@ +toContain('class="mx-auto mt-6 flex w-full max-w-3xl flex-col items-center gap-3"'); +}); + +test('server validation opens in the centered process dialog', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->toContain('') + ->toContain('@click="processDialogOpen = true"') + ->not->toContain(''); +}); + +test('DigitalOcean is available as an onboarding server provider', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->toContain('') + ->toContain('') + ->toContain('Deploy servers directly from your DigitalOcean account.') + ->toContain(''); +}); + +test('Hostinger is available as an onboarding server provider', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->toContain('') + ->toContain('') + ->toContain('Deploy servers directly from your Hostinger account.') + ->toContain(''); +}); + +test('server type details are shown on the relevant cards instead of a technical details panel', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->toContain('aria-label="About this machine"') + ->toContain('aria-label="About remote servers"') + ->toContain('Not recommended for production workloads due to resource contention.') + ->toContain('Any SSH-accessible server, including cloud VPS, bare metal, and self-hosted infrastructure.') + ->not->toContain('') + ->not->toContain('') + ->not->toContain(''); +}); + +test('server type cards use the standard card hover treatment', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + $hoverClasses = 'shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md'; + preg_match_all('/min-h-36[^\"]*'.preg_quote($hoverClasses, '/').'/', $view, $matches); + + expect($matches[0])->toHaveCount(6) + ->and(substr_count($view, 'group relative cursor-pointer'))->toBe(6) + ->and($view)->not->toContain('hover:border-coollabs/35 hover:bg-coollabs/[0.03]') + ->and($view)->not->toContain('dark:hover:border-warning/25 dark:hover:bg-warning/[0.04]'); +}); + +test('existing SSH key selection does not show a redundant value tooltip', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view)->toContain('label="Existing SSH key" :options="$privateKeyOptions" :tooltip="false"'); +}); + +test('server connection step does not render a technical details panel', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->not->toContain('') + ->not->toContain('') + ->not->toContain(''); +}); + +test('project step does not render a technical details panel', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->not->toContain('') + ->not->toContain('') + ->not->toContain(''); +}); diff --git a/tests/Feature/CloudProviderLinkDropdownTest.php b/tests/Feature/CloudProviderLinkDropdownTest.php index 1f14482cf6..714b896e01 100644 --- a/tests/Feature/CloudProviderLinkDropdownTest.php +++ b/tests/Feature/CloudProviderLinkDropdownTest.php @@ -59,19 +59,66 @@ it('shows link cloud provider dropdown with available unlinked providers', funct 'name' => 'Test DigitalOcean Token', ]); + CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'test-hostinger-token', + 'name' => 'Test Hostinger Token', + ]); + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) ->assertSee('Link provider') ->assertSee('Hetzner') ->assertSee('DigitalOcean') ->assertSee('Vultr') + ->assertSee('Hostinger') ->assertSee('Hetzner Token') ->assertSee('DigitalOcean Token') ->assertSee('Vultr Token') + ->assertSee('Hostinger Token') ->assertSee('Server ID') ->assertSee('Droplet ID') ->assertSee('Instance ID') + ->assertSee('Virtual Machine ID') ->assertSee('Search by server IP') - ->assertSee('Search'); + ->assertSee('Search ID'); +}); + +it('links a manually added server to a Hostinger VPS by IP', function () { + $token = CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'test-hostinger-token', + 'name' => 'Test Hostinger Token', + ]); + + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + [ + 'id' => 17923, + 'hostname' => 'hostinger.example.com', + 'state' => 'running', + 'ipv4' => [['address' => '1.2.3.4']], + ], + ]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'hostname' => 'hostinger.example.com', + 'state' => 'running', + 'ipv4' => [['address' => '1.2.3.4']], + ]), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('selectedHostingerTokenId', $token->id) + ->call('searchHostingerVirtualMachine') + ->assertSet('matchedHostingerVirtualMachine.id', 17923) + ->call('linkToHostinger') + ->assertDispatched('success'); + + expect($this->server->fresh()->hostinger_virtual_machine_id)->toBe(17923) + ->and($this->server->fresh()->hostinger_virtual_machine_status)->toBe('running') + ->and($this->server->fresh()->cloud_provider_token_id)->toBe($token->id); }); it('hides link cloud provider dropdown when no providers can be linked', function () { diff --git a/tests/Feature/CloudProviderTokenShowTest.php b/tests/Feature/CloudProviderTokenShowTest.php index fad6be2287..1c657c644c 100644 --- a/tests/Feature/CloudProviderTokenShowTest.php +++ b/tests/Feature/CloudProviderTokenShowTest.php @@ -49,6 +49,24 @@ test('server cloud provider token cards show token descriptions', function () { ->assertSee('Used for production servers in the EU region.'); }); +test('server cloud provider token page supports Hostinger VPS servers', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'hostinger_virtual_machine_id' => 17923, + ]); + + CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'name' => 'Production Hostinger', + ]); + + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->assertSet('provider', 'hostinger') + ->assertSet('providerName', 'Hostinger') + ->assertSee('Production Hostinger'); +}); + describe('after the user switches the session team', function () { beforeEach(function () { $this->withoutDefer(); diff --git a/tests/Feature/DatabaseFlushTest.php b/tests/Feature/DatabaseFlushTest.php new file mode 100644 index 0000000000..476aed154d --- /dev/null +++ b/tests/Feature/DatabaseFlushTest.php @@ -0,0 +1,112 @@ + 0]); + $this->withoutDefer(); +}); + +function createFlushableDatabase(string $model, array $attributes = []): StandaloneRedis|StandaloneKeydb|StandaloneDragonfly +{ + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + 'user' => 'deploy', + ]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + + return $model::create([ + 'name' => 'cache', + 'image' => 'redis:7', + 'environment_id' => Environment::factory()->create(['project_id' => $project->id])->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ...$attributes, + ]); +} + +function actingAsFlushUser(Team $team, string $role): void +{ + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => $role]); + test()->actingAs($user); + session(['currentTeam' => $team]); +} + +it('flushes every key with the engine CLI', function (string $model, array $attributes, string $cli) { + $database = createFlushableDatabase($model, $attributes); + actingAsFlushUser($database->team(), 'owner'); + Process::fake(['*' => Process::result(output: 'OK')]); + + Livewire::test(Danger::class, ['resource' => $database]) + ->call('flush', 'password') + ->assertReturned(true) + ->assertDispatched('success'); + + Process::assertRan(fn ($process) => str_contains($process->command, "sudo bash -c 'docker exec {$database->uuid} sh -c '\\''REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'\\'''")); + expect(AuditEvent::query()->where('event', 'ui.database.flushed')->where('resource_uuid', $database->uuid)->exists())->toBeTrue(); +})->with([ + 'redis' => [StandaloneRedis::class, [], 'redis-cli'], + 'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'secret'], 'keydb-cli'], + 'dragonfly with tls' => [StandaloneDragonfly::class, ['dragonfly_password' => 'secret', 'enable_ssl' => true], 'redis-cli --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key'], +]); + +it('reports an error when the database does not reply OK', function () { + $database = createFlushableDatabase(StandaloneRedis::class); + actingAsFlushUser($database->team(), 'owner'); + Process::fake(['*' => Process::result(output: 'NOAUTH Authentication required.')]); + + Livewire::test(Danger::class, ['resource' => $database]) + ->call('flush', 'password') + ->assertReturned(false) + ->assertDispatched('error', 'NOAUTH Authentication required.'); + + expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse(); +}); + +it('rejects an incorrect password without flushing', function () { + $database = createFlushableDatabase(StandaloneRedis::class); + actingAsFlushUser($database->team(), 'owner'); + Process::fake(); + + Livewire::test(Danger::class, ['resource' => $database]) + ->call('flush', 'wrong-password') + ->assertReturned('The provided password is incorrect.'); + + Process::assertNothingRan(); +}); + +it('forbids flushing without permission to manage the database', function (string $role, bool $sameTeam) { + $database = createFlushableDatabase(StandaloneRedis::class); + actingAsFlushUser($sameTeam ? $database->team() : Team::factory()->create(), $role); + Process::fake(); + + Livewire::test(Danger::class, ['resource' => $database]) + ->call('flush', 'password') + ->assertDispatched('error'); + + Process::assertNothingRan(); +})->with([ + 'team member' => ['member', true], + 'admin of another team' => ['admin', false], +]); diff --git a/tests/Feature/DeploymentCancellationCleanupTest.php b/tests/Feature/DeploymentCancellationCleanupTest.php index 6912ae604a..0721fbc4d2 100644 --- a/tests/Feature/DeploymentCancellationCleanupTest.php +++ b/tests/Feature/DeploymentCancellationCleanupTest.php @@ -179,3 +179,16 @@ test('cancelling after switching to another team still cleans up and starts the assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment); expect($queued->fresh()->status)->toBe(ApplicationDeploymentStatus::IN_PROGRESS->value); }); + +test('cancelling a Railpack deployment stops the idle Railpack builder on the build server', function (string $buildPack, bool $stopsBuilder) { + $this->application->update(['build_pack' => $buildPack]); + $this->actingAs($this->user); + + Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment]) + ->call('cancel'); + + expect(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.20', 'docker buildx stop coolify-railpack') !== [])->toBe($stopsBuilder); +})->with([ + 'railpack' => ['railpack', true], + 'nixpacks' => ['nixpacks', false], +]); diff --git a/tests/Feature/DevScriptTest.php b/tests/Feature/DevScriptTest.php index 1864c012f9..232bde15de 100644 --- a/tests/Feature/DevScriptTest.php +++ b/tests/Feature/DevScriptTest.php @@ -245,6 +245,30 @@ BASH); ->not->toContain('--profile testing-host up'); }); +it('runs the host qemu command with a clean local environment', function () { + if (! function_exists('posix_geteuid') || posix_geteuid() !== 0 || ! file_exists('/dev/kvm') || filetype('/dev/kvm') !== 'char' || ! is_readable('/dev/kvm') || ! is_writable('/dev/kvm')) { + $this->markTestSkipped('KVM and root access are required for this launcher branch.'); + } + + file_put_contents($this->devRoot.'/bin/php', <<<'BASH' +#!/usr/bin/env bash +[[ "$APP_ENV" == local && "$APP_CONFIG_CACHE" == */bootstrap/cache/dev-qemu-*.php ]] || { + echo 'This command may only run in development mode.' >&2 + exit 1 +} +printf 'php %s ENV=%s CACHE=%s\n' "$*" "$APP_ENV" "$APP_CONFIG_CACHE" >> "$DEV_TEST_LOG" +BASH); + + $process = runDevScript('wt', ['start', 'ubuntu-root'], [ + 'COOLIFY_DEV_SERVER_BACKEND' => 'auto', + 'APP_ENV' => 'production', + 'APP_CONFIG_CACHE' => '/tmp/stale-config.php', + ]); + + expect($process->isSuccessful())->toBeTrue($process->getErrorOutput()) + ->and(devScriptLog())->toContain('php artisan dev:qemu ubuntu-root --as-localhost ENV=local CACHE='); +}); + it('starts a lima vm as localhost on macos and seeds its forwarded ssh port', function () { ($this->fakeLima)(); runDevScript('main', ['start']); diff --git a/tests/Feature/HostingerApiTest.php b/tests/Feature/HostingerApiTest.php new file mode 100644 index 0000000000..bdb5c309e4 --- /dev/null +++ b/tests/Feature/HostingerApiTest.php @@ -0,0 +1,273 @@ +whereKey(0)->delete(); + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([ + 'id' => 0, + 'is_api_enabled' => true, + ])); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->bearerToken = $this->user->createToken('test-token', ['*'])->plainTextToken; + $this->hostingerToken = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'hostinger-api-token', + ]); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); +}); + +it('gets Hostinger data centers', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + ['id' => 19, 'city' => 'Amsterdam', 'location' => 'nl'], + ]), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/data-centers?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertSuccessful() + ->assertJsonFragment(['id' => 19, 'city' => 'Amsterdam']); +}); + +it('gets Hostinger SSH keys and post-install scripts', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response([ + 'data' => [['id' => 42, 'name' => 'Operations']], + ]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response([ + 'data' => [['id' => 73, 'name' => 'Bootstrap Coolify']], + ]), + ]); + + $query = '?cloud_provider_token_id='.$this->hostingerToken->uuid; + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/ssh-keys'.$query) + ->assertSuccessful() + ->assertJsonFragment(['id' => 42, 'name' => 'Operations']); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/post-install-scripts'.$query) + ->assertSuccessful() + ->assertJsonFragment(['id' => 73, 'name' => 'Bootstrap Coolify']); +}); + +it('gets only Hostinger KVM plans through the API', function () { + Http::fake([ + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + ['id' => 'hostingercom-vps-kvm1', 'name' => 'KVM 1', 'prices' => []], + ['id' => 'hostingercom-vps-kvmminecraftalex', 'name' => 'Game Panel 1', 'prices' => []], + ]), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/catalog?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertSuccessful() + ->assertJsonCount(1) + ->assertJsonFragment(['id' => 'hostingercom-vps-kvm1']) + ->assertJsonMissing(['id' => 'hostingercom-vps-kvmminecraftalex']); +}); + +it('returns 403 with the Hostinger message when Hostinger denies a provider list', function (int $hostingerStatus) { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response([ + 'message' => '[VPS:2000] Unauthorized', + ], $hostingerStatus), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/post-install-scripts?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertForbidden() + ->assertExactJson(['message' => 'Hostinger denied access to post-install scripts: [VPS:2000] Unauthorized']); +})->with([401, 403]); + +it('returns 429 when Hostinger rate limits a provider list', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + 'message' => 'Too many requests.', + ], 429, ['Retry-After' => '30']), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/templates?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertStatus(429) + ->assertHeader('Retry-After', '30') + ->assertExactJson(['message' => 'Rate limit exceeded. Please try again later.']); +}); + +it('returns a generic error when a Hostinger provider list fails', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + 'message' => 'Invalid request', + ], 422), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/data-centers?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertServerError() + ->assertExactJson(['message' => 'Failed to fetch Hostinger data centers.']); +}); + +it('creates a Hostinger VPS server through the API', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'order' => ['id' => 2957086, 'status' => 'completed'], + 'virtual_machine' => [ + 'id' => 17923, + 'state' => 'creating', + 'ipv4' => [['address' => '203.0.113.10']], + ], + ]), + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_id' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm2-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1130, + 'name' => 'api-hostinger.example.com', + 'private_key_uuid' => $this->privateKey->uuid, + 'enable_backups' => true, + ]) + ->assertCreated() + ->assertJsonFragment([ + 'hostinger_virtual_machine_id' => 17923, + 'ip' => '203.0.113.10', + ]); + + $this->assertDatabaseHas('servers', [ + 'team_id' => $this->team->id, + 'hostinger_virtual_machine_id' => 17923, + 'ip' => '203.0.113.10', + ]); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request['setup']['enable_backups'] === true); +}); + +it('turns paid Hostinger backups off when the API request does not enable them', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => [ + 'id' => 17923, + 'state' => 'creating', + 'ipv4' => [['address' => '203.0.113.10']], + ], + ]), + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_uuid' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1130, + 'private_key_uuid' => $this->privateKey->uuid, + ]) + ->assertCreated(); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request['setup']['enable_backups'] === false); +}); + +it('keeps a charged Hostinger VPS when the purchase returns an error through the API', function () { + $listRequests = 0; + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/2008976/setup' => Http::response([ + 'id' => 2008976, + 'state' => 'creating', + 'ipv4' => [['address' => '72.61.180.163']], + ]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => function ($request) use (&$listRequests) { + if ($request->method() === 'POST') { + return Http::response(['message' => '[VPS:2004] Wrong hostname FQDN format'], 422); + } + + return Http::response($listRequests++ === 0 ? [] : [['id' => 2008976, 'state' => 'initial']]); + }, + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_id' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1077, + 'private_key_uuid' => $this->privateKey->uuid, + ]) + ->assertCreated() + ->assertJsonFragment([ + 'hostinger_virtual_machine_id' => 2008976, + 'ip' => '72.61.180.163', + ]); + + $this->assertDatabaseHas('servers', [ + 'team_id' => $this->team->id, + 'hostinger_virtual_machine_id' => 2008976, + 'ip' => '72.61.180.163', + ]); +}); + +it('reports a Hostinger order that is waiting for payment through the API', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'id' => 2957086, + 'status' => 'payment_initiated', + ], 202), + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_id' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm2-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1130, + 'private_key_uuid' => $this->privateKey->uuid, + ]) + ->assertStatus(202) + ->assertJsonFragment(['message' => 'Hostinger order 2957086 is waiting for payment. Finish the VPS setup in hPanel.']); + + $this->assertDatabaseMissing('servers', ['team_id' => $this->team->id]); +}); + +it('accepts Hostinger cloud provider tokens through the API', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([]), + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/cloud-tokens', [ + 'provider' => 'hostinger', + 'token' => 'another-hostinger-token', + 'name' => 'Another Hostinger Token', + ]) + ->assertCreated(); + + $this->assertDatabaseHas('cloud_provider_tokens', [ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'name' => 'Another Hostinger Token', + ]); +}); diff --git a/tests/Feature/HostingerServerCreationTest.php b/tests/Feature/HostingerServerCreationTest.php new file mode 100644 index 0000000000..48f24aa2eb --- /dev/null +++ b/tests/Feature/HostingerServerCreationTest.php @@ -0,0 +1,484 @@ + 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([ + 'id' => 0, + ])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'hostinger-token', + 'name' => 'Production Hostinger', + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('offers Hostinger as a server provider', function () { + Livewire::test(Create::class) + ->assertSee('Hostinger') + ->assertSee(route('server.create.type', ['type' => 'hostinger']), false); + + Livewire::test(CreatePage::class, ['type' => 'hostinger']) + ->assertSet('title', 'Hostinger') + ->assertSet('tokenProvider', 'hostinger'); +}); + +it('uses the current cloud provider UI and Hostinger affiliate link', function () { + Livewire::test(ByHostinger::class) + ->assertSee('Hostinger account') + ->assertSee('https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io', false) + ->assertSee("through Coolify's affiliate link.", false); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->set('loading_data', false) + ->assertSee('Hostinger server') + ->assertSee('Advanced options') + ->assertSee('Buy and create'); +}); + +it('leaves paid Hostinger backups off by default', function () { + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->assertSet('enable_backups', false) + ->set('loading_data', false) + ->assertSee('Enable weekly Hostinger backups (extra cost)') + ->assertSee('Hostinger charges extra for backups. Check the price in hPanel before you enable them.'); +}); + +it('selects Hostinger account SSH keys with the same dropdown as Hetzner', function () { + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->set('loading_data', false) + ->set('hostinger_public_keys', [['id' => 42, 'name' => 'Operations']]) + ->assertSee('Extra SSH keys') + ->assertSee('Existing keys from the Hostinger account.') + ->assertSee('Search SSH keys') + ->assertSeeHtml('') + ->assertDontSeeHtml('Operations') + ->assertDontSeeHtml('id="hostinger-public-key-42-'); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->set('loading_data', false) + ->set('hostinger_public_keys', []) + ->assertSee('No account keys found'); +}); + +it('purchases a Hostinger VPS and creates the linked Coolify server', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + ['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam', 'location' => 'nl'], + ]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + ['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'], + ]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + [ + 'id' => 'hostingercom-vps-kvm2', + 'name' => 'KVM 2', + 'category' => 'VPS', + 'prices' => [ + [ + 'id' => 'hostingercom-vps-kvm2-usd-1m', + 'currency' => 'USD', + 'price' => 1799, + 'first_period_price' => 899, + 'period' => 1, + 'period_unit' => 'month', + ], + ], + ], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'order' => ['id' => 2957086, 'status' => 'completed'], + 'virtual_machine' => [ + 'id' => 17923, + 'hostname' => 'coolify-hostinger.example.com', + 'state' => 'creating', + 'ipv4' => [['address' => '203.0.113.10']], + ], + ]), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'coolify-hostinger.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_template_id', 1130) + ->set('selected_price_id', 'hostingercom-vps-kvm2-usd-1m') + ->set('private_key_id', $this->privateKey->id) + ->set('enable_backups', true) + ->call('submit') + ->assertHasNoErrors(); + + $server = Server::query()->where('hostinger_virtual_machine_id', 17923)->firstOrFail(); + + expect($server->name)->toBe('coolify-hostinger.example.com') + ->and($server->ip)->toBe('203.0.113.10') + ->and($server->hostinger_virtual_machine_status)->toBe('creating') + ->and($server->cloud_provider_token_id)->toBe($this->token->id); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request['item_id'] === 'hostingercom-vps-kvm2-usd-1m' + && $request['setup']['hostname'] === 'coolify-hostinger.example.com' + && $request['setup']['public_key']['key'] === $this->privateKey->getPublicKey()); +}); + +it('keeps a purchased Hostinger VPS linked while its public IP is pending', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([['id' => 19, 'name' => 'nl-ams', 'city' => null]]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([['id' => 1130, 'name' => 'Ubuntu']]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + ['id' => 'hostingercom-vps-kvm2', 'name' => 'KVM 2', 'prices' => [['id' => 'kvm2-monthly']]], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => ['id' => 17923, 'state' => 'creating', 'ipv4' => []], + ]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'message' => 'Still provisioning', + ], 503), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'pending-hostinger.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_template_id', 1130) + ->set('selected_price_id', 'kvm2-monthly') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasNoErrors(); + + expect(Server::query()->where('hostinger_virtual_machine_id', 17923)->firstOrFail()->ip) + ->toBe(Server::PLACEHOLDER_IP); +}); + +it('loads the Hostinger form when optional provider lists are not authorized', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam']]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([['id' => 1130, 'name' => 'Ubuntu']]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + ['id' => 'hostingercom-vps-kvm2', 'name' => 'KVM 2', 'prices' => [['id' => 'kvm2-monthly']]], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['message' => '[VPS:2000] Unauthorized'], 403), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['message' => '[VPS:2000] Unauthorized'], 403), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => ['id' => 17923, 'state' => 'creating', 'ipv4' => [['address' => '203.0.113.10']]], + ]), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->assertSet('provider_data_error', null) + ->assertSet('hostinger_public_keys', []) + ->assertSet('post_install_scripts', []) + ->assertSee('Buy and create') + ->set('server_name', 'optional-lists.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_template_id', 1130) + ->set('selected_price_id', 'kvm2-monthly') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasNoErrors(); + + expect(Server::query()->where('hostinger_virtual_machine_id', 17923)->exists())->toBeTrue(); +}); + +it('revalidates the selected Hostinger price before making a purchase', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + ['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam'], + ]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + ['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'], + ]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + [ + 'id' => 'hostingercom-vps-kvm2', + 'name' => 'KVM 2', + 'prices' => [['id' => 'hostingercom-vps-kvm2-usd-1m']], + ], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'coolify-hostinger.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_template_id', 1130) + ->set('selected_price_id', 'tampered-price-id') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasErrors('selected_price_id'); + + Http::assertNotSent(fn ($request) => $request->method() === 'POST'); +}); + +function fakeHostingerProvisioningOptions(array $overrides = []): void +{ + $price = fn (string $plan, int $period, string $unit, string $suffix) => [ + 'id' => "hostingercom-vps-{$plan}-usd-{$suffix}", + 'currency' => 'USD', + 'price' => 1000, + 'first_period_price' => 500, + 'period' => $period, + 'period_unit' => $unit, + ]; + $prices = fn (string $plan) => [ + $price($plan, 2, 'year', '2y'), + $price($plan, 1, 'month', '1m'), + $price($plan, 1, 'year', '1y'), + ]; + + Http::fake(array_merge([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + ['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam', 'location' => 'nl'], + ]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + ['id' => 1121, 'name' => 'AlmaLinux 9 with CyberPanel'], + ['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'], + ['id' => 1077, 'name' => 'Debian 13'], + ['id' => 1150, 'name' => 'Ubuntu 24.04 with Coolify'], + ]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + ['id' => 'hostingercom-vps-kvm8', 'name' => 'KVM 8', 'metadata' => ['cpus' => 8], 'prices' => $prices('kvm8')], + ['id' => 'hostingercom-vps-kvmminecraftalex', 'name' => 'Game Panel 1', 'metadata' => ['cpus' => 1], 'prices' => $prices('kvmminecraftalex')], + ['id' => 'hostingercom-vps-kvm1', 'name' => 'KVM 1', 'metadata' => ['cpus' => 1], 'prices' => $prices('kvm1')], + ['id' => 'hostingercom-vps-kvm2', 'name' => 'KVM 2', 'metadata' => ['cpus' => 2], 'prices' => $prices('kvm2')], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]), + ], $overrides)); +} + +it('lists only Hostinger KVM plans sorted by size and billing period', function () { + fakeHostingerProvisioningOptions(); + + $component = Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData'); + + expect(collect($component->instance()->priceOptions)->pluck('id')->all())->toBe([ + 'hostingercom-vps-kvm1-usd-1m', + 'hostingercom-vps-kvm1-usd-1y', + 'hostingercom-vps-kvm1-usd-2y', + 'hostingercom-vps-kvm2-usd-1m', + 'hostingercom-vps-kvm2-usd-1y', + 'hostingercom-vps-kvm2-usd-2y', + 'hostingercom-vps-kvm8-usd-1m', + 'hostingercom-vps-kvm8-usd-1y', + 'hostingercom-vps-kvm8-usd-2y', + ]); + + $component->assertDontSee('Game Panel'); +}); + +it('splits Hostinger templates into plain operating systems and operating systems with applications', function () { + fakeHostingerProvisioningOptions(); + + $component = Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->assertSet('selected_os_template_id', 1130) + ->assertSet('selected_app_template_id', null) + ->assertSet('selected_template_id', 1130) + ->assertSee('Operating system with application') + ->assertSee('can conflict with the Coolify proxy on ports 80 and 443', false); + + expect(collect($component->instance()->osTemplates)->pluck('name')->all()) + ->toBe(['Debian 13', 'Ubuntu 24.04 LTS']) + ->and(collect($component->instance()->appTemplates)->pluck('name')->all()) + ->toBe(['AlmaLinux 9 with CyberPanel', 'Ubuntu 24.04 with Coolify']); +}); + +it('keeps only one Hostinger operating system selection', function () { + fakeHostingerProvisioningOptions(); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('selected_app_template_id', 1150) + ->assertSet('selected_os_template_id', null) + ->assertSet('selected_template_id', 1150) + ->set('selected_os_template_id', 1077) + ->assertSet('selected_app_template_id', null) + ->assertSet('selected_template_id', 1077); +}); + +it('purchases a Hostinger VPS with an operating system that includes an application', function () { + fakeHostingerProvisioningOptions([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => ['id' => 17923, 'state' => 'creating', 'ipv4' => [['address' => '203.0.113.10']]], + ]), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'app-template.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_price_id', 'hostingercom-vps-kvm2-usd-1m') + ->set('selected_app_template_id', 1150) + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasNoErrors(); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request['setup']['template_id'] === 1150); +}); + +it('rejects a Game Panel Hostinger plan before making a purchase', function () { + fakeHostingerProvisioningOptions(); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'game-panel.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_price_id', 'hostingercom-vps-kvmminecraftalex-usd-1m') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasErrors('selected_price_id'); + + Http::assertNotSent(fn ($request) => $request->method() === 'POST'); +}); + +it('starts a stopped Hostinger VPS from the server page', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'stopped', + ]); + + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923/start' => Http::response([ + 'id' => 456, + 'state' => 'starting', + ]), + ]); + + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->call('startHostingerVirtualMachine') + ->assertSet('hostingerVirtualMachineStatus', 'starting') + ->assertDispatched('success'); + + expect($server->fresh()->hostinger_virtual_machine_status)->toBe('starting'); +}); + +it('labels the Hostinger status refresh button on the server page', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'running', + ]); + + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->assertSee('Hostinger · Running') + ->assertSee('Refresh status') + ->assertSeeHtml('wire:click.prevent="checkHostingerVirtualMachineStatus(true)"'); +}); + +it('does not validate a stopped Hostinger VPS over SSH', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'stopped', + ]); + + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'state' => 'stopped', + 'ipv4' => [['address' => $server->ip]], + ]), + ]); + + Livewire::test(ValidateAndInstall::class, ['server' => $server]) + ->call('validateConnection') + ->assertSet('error', 'Hostinger VPS is stopped. Power it on before validating.'); +}); + +it('warns that deleting from Coolify does not cancel the Hostinger VPS', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + ]); + + Livewire::test(Delete::class, ['server_uuid' => $server->uuid]) + ->assertSee('Hostinger deletes the VPS only at the end of its paid period, and only if you select this in the confirmation dialog.') + ->assertSee('https://hpanel.hostinger.com/', false) + ->assertSeeHtml('Hostinger deletes a VPS only at the end of its paid period.') + ->assertSee('The VPS on Hostinger will NOT be deleted, and Hostinger will keep billing for it.'); +}); + +it('passes the Hostinger deletion choice to the server deletion', function () { + Queue::fake(); + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + ]); + + Livewire::test(Delete::class, ['server_uuid' => $server->uuid]) + ->assertSee('Also delete VPS from Hostinger (at the end of the paid period)') + ->call('delete', 'password', ['delete_from_hostinger']); + + Queue::assertPushed(JobDecorator::class, fn (JobDecorator $job) => $job->getAction() instanceof DeleteServer + && $job->getParameters()[9] === true + && $job->getParameters()[10] === 17923); +}); + +it('keeps the default deletion warning for servers without a Hostinger VPS', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + Livewire::test(Delete::class, ['server_uuid' => $server->uuid]) + ->assertSee('This operation is permanent and cannot be undone.') + ->assertDontSee('Hostinger deletes a VPS'); +}); diff --git a/tests/Feature/ModelFillableCreationTest.php b/tests/Feature/ModelFillableCreationTest.php index 54870d3f8c..a71e1ea041 100644 --- a/tests/Feature/ModelFillableCreationTest.php +++ b/tests/Feature/ModelFillableCreationTest.php @@ -87,6 +87,8 @@ it('creates Server with all fillable attributes', function () { 'hetzner_server_status' => 'running', 'digitalocean_droplet_id' => 987654, 'digitalocean_droplet_status' => 'active', + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'running', 'is_validating' => false, 'detected_traefik_version' => 'v2.10.0', 'traefik_outdated_info' => 'Up to date', @@ -106,6 +108,8 @@ it('creates Server with all fillable attributes', function () { expect($server->hetzner_server_status)->toBe('running'); expect($server->digitalocean_droplet_id)->toBe(987654); expect($server->digitalocean_droplet_status)->toBe('active'); + expect($server->hostinger_virtual_machine_id)->toBe(17923); + expect($server->hostinger_virtual_machine_status)->toBe('running'); expect($server->ip_previous)->toBe('10.0.0.1'); }); diff --git a/tests/Feature/Proxy/CaddyBasicAuthLabelsTest.php b/tests/Feature/Proxy/CaddyBasicAuthLabelsTest.php index 359abef073..4f3aa36510 100644 --- a/tests/Feature/Proxy/CaddyBasicAuthLabelsTest.php +++ b/tests/Feature/Proxy/CaddyBasicAuthLabelsTest.php @@ -106,3 +106,55 @@ it('emits the matching basic auth directive in application labels', function (st 'caddy-docker-proxy 2.13, preview, all proxies' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', false, true, 'basic_auth'], 'caddy-docker-proxy 2.13, preview, exact labels' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', true, true, 'basic_auth'], ]); + +it('hashes the password in application labels with the selected settings', function (array $proxy, bool $exactLabels, bool $preview, array $hashSettings, array $expectedLabels) { + $team = Team::factory()->create(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + $server = Server::factory()->create(['team_id' => $team->id, 'proxy' => $proxy]); + $server->settings->update(['generate_exact_labels' => $exactLabels]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $application = Application::factory()->createOne([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'fqdn' => 'https://example.com', + 'is_http_basic_auth_enabled' => true, + 'http_basic_auth_username' => 'admin', + 'http_basic_auth_password' => 'secret', + ...$hashSettings, + ]); + Server::flushIdentityMap(); + + $applicationPreview = $preview + ? (new ApplicationPreview)->forceFill(['pull_request_id' => 7, 'fqdn' => 'https://pr-7.example.com']) + : null; + + $labels = collect(generateLabelsApplication($application->fresh(), $applicationPreview))->implode("\n"); + + expect($labels)->toContain(...$expectedLabels); +})->with(function () { + $caddy = basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:2.13-alpine'); + $traefik = basicAuthCaddyProxy('traefik:v3.7', ['type' => ProxyTypes::TRAEFIK->value]); + $bcrypt = ['http_basic_auth_bcrypt_cost' => 4]; + $argon2id = ['http_basic_auth_hash_algorithm' => 'argon2id', 'http_basic_auth_argon2id_memory_cost' => 8192, 'http_basic_auth_argon2id_time_cost' => 2]; + $caddyBcrypt = 'caddy_0.basic_auth.admin="$2y$04$'; + $traefikBcrypt = '.basicauth.users=admin:$2y$04$'; + + return [ + 'bcrypt, all proxies' => [$caddy, false, false, $bcrypt, [$caddyBcrypt, $traefikBcrypt]], + 'bcrypt, all proxies, preview' => [$caddy, false, true, $bcrypt, [$caddyBcrypt, $traefikBcrypt]], + 'bcrypt, exact Caddy labels' => [$caddy, true, false, $bcrypt, [$caddyBcrypt]], + 'bcrypt, exact Caddy labels, preview' => [$caddy, true, true, $bcrypt, [$caddyBcrypt]], + 'bcrypt, exact Traefik labels' => [$traefik, true, false, $bcrypt, [$traefikBcrypt]], + 'bcrypt, exact Traefik labels, preview' => [$traefik, true, true, $bcrypt, [$traefikBcrypt]], + 'Argon2id for Caddy, bcrypt for Traefik' => [$caddy, false, false, $argon2id, [ + 'caddy_0.basic_auth=argon2id', + 'caddy_0.basic_auth.admin="$argon2id$v=19$m=8192,t=2,p=1$', + '.basicauth.users=admin:$2y$10$', + ]], + 'bcrypt when Caddy is too old for the stored Argon2id' => [basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:2.10-alpine'), false, false, $argon2id, [ + 'caddy_0.basic_auth.admin="$2y$10$', + '.basicauth.users=admin:$2y$10$', + ]], + ]; +}); diff --git a/tests/Feature/Proxy/MaintenanceModeTest.php b/tests/Feature/Proxy/MaintenanceModeTest.php new file mode 100644 index 0000000000..44d8ba6d6e --- /dev/null +++ b/tests/Feature/Proxy/MaintenanceModeTest.php @@ -0,0 +1,289 @@ + Server::flushIdentityMap()); + +beforeEach(function () { + Server::flushIdentityMap(); + Event::fake([ProxyStatusChanged::class, ProxyStatusChangedUI::class]); + InstanceSettings::forceCreate(['id' => 0]); + config(['constants.ssh.mux_enabled' => false]); + fakeProxyFileUploads(); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'user' => 'cooluser', + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $this->server->proxy->type = ProxyTypes::TRAEFIK->value; + $this->server->save(); + + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first() + ?? StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); +}); + +function maintenanceApplication(array $attributes = []): Application +{ + $test = test(); + + return Application::factory()->create(array_merge([ + 'environment_id' => $test->environment->id, + 'destination_id' => $test->destination->id, + 'destination_type' => $test->destination->getMorphClass(), + 'fqdn' => 'https://app.example.com', + ], $attributes)); +} + +function maintenanceService(array $domains): Service +{ + $test = test(); + $service = Service::factory()->create([ + 'environment_id' => $test->environment->id, + 'server_id' => $test->server->id, + 'destination_id' => $test->destination->id, + 'destination_type' => $test->destination->getMorphClass(), + ]); + foreach ($domains as $name => $fqdn) { + ServiceApplication::create(['name' => $name, 'service_id' => $service->id, 'image' => 'nginx', 'fqdn' => $fqdn]); + } + + return $service; +} + +function maintenanceServer(): Server +{ + return Server::find(test()->server->id); +} + +/** + * @return array + */ +function maintenanceScriptLines(): array +{ + $lines = []; + Process::assertRan(function ($process) use (&$lines) { + array_push($lines, ...explode("\n", $process->command)); + + return true; + }); + + return $lines; +} + +it('routes the maintenance domains through Traefik with a priority just above the resource router', function () { + $application = maintenanceApplication(['fqdn' => 'https://app.example.com,http://app.example.com/api', 'is_maintenance_enabled' => true]); + + $config = Yaml::parse(maintenanceServer()->maintenanceTraefikConfiguration(collect([$application]))); + $routers = $config['http']['routers']; + $rootRule = 'Host(`app.example.com`) && PathPrefix(`/`)'; + $apiRule = 'Host(`app.example.com`) && PathPrefix(`/api`)'; + + expect($routers["coolify-maintenance-{$application->uuid}-0-http"])->toMatchArray([ + 'entryPoints' => ['http'], + 'rule' => $rootRule, + 'service' => 'coolify-maintenance-noop', + 'middlewares' => ['coolify-maintenance-headers', "coolify-maintenance-{$application->uuid}"], + 'priority' => strlen($rootRule) + 1, + ]) + ->and($routers["coolify-maintenance-{$application->uuid}-0-https"]['tls'])->toBe(['certResolver' => 'letsencrypt']) + // http:// domains get no TLS router. + ->and($routers)->not->toHaveKey("coolify-maintenance-{$application->uuid}-1-https") + ->and($routers["coolify-maintenance-{$application->uuid}-1-http"]['priority'])->toBe(strlen($apiRule) + 1) + // A resource router on a longer path of the same host keeps a higher priority. + ->and(strlen($apiRule))->toBeGreaterThan($routers["coolify-maintenance-{$application->uuid}-0-http"]['priority']) + ->and($config['http']['middlewares']["coolify-maintenance-{$application->uuid}"]['errors'])->toBe([ + 'status' => ['503'], + 'service' => 'coolify-maintenance-pages', + 'query' => "/maintenance-{$application->uuid}.html", + ]) + ->and($config['http']['middlewares']['coolify-maintenance-headers']['headers']['customResponseHeaders'])->toBe(['Retry-After' => '300']) + ->and($config['http']['services']['coolify-maintenance-noop']['loadBalancer']['servers'])->toBe([]) + ->and($config['http']['services']['coolify-maintenance-pages']['loadBalancer']['servers'])->toBe([['url' => 'http://coolify-proxy-error-pages:80']]); +}); + +it('writes Caddy maintenance blocks with the same site address as the resource labels', function (bool $forceHttps, string $domain) { + $application = maintenanceApplication(['fqdn' => $domain, 'is_maintenance_enabled' => true]); + $application->settings->update(['is_force_https_enabled' => $forceHttps]); + $application->refresh(); + $labelAddress = str(fqdnLabelsForCaddy('coolify', $application->uuid, collect([$domain]), $forceHttps)->first(fn ($label) => str_starts_with($label, 'caddy_0=')))->after('caddy_0=')->value(); + $matcher = "@coolify-maintenance-{$application->uuid}-0"; + $path = parse_url($domain, PHP_URL_PATH) ?: '/'; + + $caddyfile = maintenanceServer()->caddyBaseCaddyfile(collect([$application])); + + expect($caddyfile) + ->toStartWith("import /dynamic/*.caddy\n") + ->toContain("\n\n{$labelAddress} {\n\t{$matcher} path {$path}*\n\thandle {$matcher} {") + ->toContain("rewrite /maintenance-{$application->uuid}.html") + ->toContain('copy_response 503'); +})->with([ + 'https' => [false, 'https://app.example.com'], + 'forced https' => [true, 'https://app.example.com'], + 'http with path' => [false, 'http://app.example.com/api'], +]); + +it('keeps the base Caddyfile to the import line without maintenance resources', function () { + expect(maintenanceServer()->caddyBaseCaddyfile(collect()))->toBe("import /dynamic/*.caddy\n"); +}); + +it('skips domains that could inject proxy configuration', function () { + $application = maintenanceApplication(['fqdn' => 'https://ok.example.com,https://bad.example.com/a b,https://bad.example.com/{x},https://evil.example.com`)', 'is_maintenance_enabled' => true]); + + expect($application->maintenanceRoutes()->pluck('host')->all())->toBe(['ok.example.com']); +}); + +it('collects the domains of Docker Compose applications and services', function () { + $compose = maintenanceApplication([ + 'build_pack' => 'dockercompose', + 'fqdn' => null, + 'docker_compose_domains' => json_encode(['web' => ['domain' => 'https://web.example.com,https://www.example.com'], 'api' => ['domain' => 'https://web.example.com/api'], 'db' => ['domain' => '']]), + ]); + $service = maintenanceService(['ghost' => 'https://blog.example.com', 'admin' => 'https://admin.example.com/ghost']); + + expect($compose->maintenanceRoutes()->map(fn ($route) => $route['host'].$route['path'])->all())->toBe(['web.example.com/', 'www.example.com/', 'web.example.com/api']) + ->and($service->maintenanceRoutes()->map(fn ($route) => $route['host'].$route['path'])->all())->toBe(['blog.example.com/', 'admin.example.com/ghost']); +}); + +it('writes the pages and the Traefik routes through sudo, and removes them when maintenance ends', function () { + $application = maintenanceApplication(['is_maintenance_enabled' => true]); + $service = maintenanceService(['ghost' => 'https://blog.example.com']); + $service->forceFill(['is_maintenance_enabled' => true])->save(); + $pages = '/data/coolify/proxy/error-pages'; + + maintenanceServer()->setupMaintenancePages(); + $lines = maintenanceScriptLines(); + + expect($lines)->toContain("sudo find $pages -maxdepth 1 -name 'maintenance-*.html' ! -name 'maintenance-{$application->uuid}.html' ! -name 'maintenance-{$service->uuid}.html' -delete") + ->and(collect($lines)->contains(fn ($line) => str_starts_with($line, 'sudo docker start coolify-proxy-error-pages')))->toBeTrue() + ->and(writtenProxyFile($lines, "$pages/maintenance-{$application->uuid}.html"))->toContain('We are doing some maintenance') + ->and(writtenProxyFile($lines, "$pages/maintenance-{$service->uuid}.html"))->toContain('We are doing some maintenance') + ->and(writtenProxyFile($lines, '/data/coolify/proxy//dynamic/coolify-maintenance.yaml')) + ->toContain('app.example.com')->toContain('blog.example.com'); + + $application->forceFill(['is_maintenance_enabled' => false])->save(); + $service->forceFill(['is_maintenance_enabled' => false])->save(); + maintenanceServer()->setupMaintenancePages(); + + expect(maintenanceScriptLines())->toContain('sudo rm -f /data/coolify/proxy//dynamic/coolify-maintenance.yaml') + ->toContain("sudo find $pages -maxdepth 1 -name 'maintenance-*.html' -delete"); +}); + +it('writes the maintenance blocks into the base Caddyfile on Caddy servers', function () { + $this->server->proxy->type = ProxyTypes::CADDY->value; + $this->server->save(); + $application = maintenanceApplication(['is_maintenance_enabled' => true]); + + maintenanceServer()->setupMaintenancePages(); + + expect(writtenProxyFile(maintenanceScriptLines(), '/data/coolify/proxy/caddy/dynamic/Caddyfile')) + ->toContain("@coolify-maintenance-{$application->uuid}-0 path /*"); +}); + +it('keeps the page container for maintenance when a redirect URL replaces the catch-all page', function () { + maintenanceApplication(['is_maintenance_enabled' => true]); + $server = maintenanceServer(); + $server->proxy->redirect_url = 'https://example.com'; + $server->save(); + + $server->setupProxyErrorPageContainer(); + + expect(collect(maintenanceScriptLines())->contains(fn ($line) => str_contains($line, 'docker run -d --name coolify-proxy-error-pages')))->toBeTrue(); +}); + +it('does not touch Swarm servers', function () { + maintenanceApplication(['is_maintenance_enabled' => true]); + $this->server->settings->update(['is_swarm_manager' => true]); + + $server = maintenanceServer(); + $server->setupMaintenancePages(); + + expect($server->maintenanceResources())->toBeEmpty(); + Process::assertNothingRan(); +}); + +function maintenanceUser(Team $team, string $role): User +{ + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => $role]); + + return $user; +} + +it('lets an admin enable maintenance and save a custom page', function () { + $application = maintenanceApplication(); + $this->actingAs(maintenanceUser($this->team, 'admin')); + session(['currentTeam' => $this->team]); + + Livewire::test(Maintenance::class, ['resource' => $application]) + ->set('maintenancePage', '

Back soon

') + ->call('submit') + ->assertDispatched('success') + ->set('isMaintenanceEnabled', true) + ->call('instantSaveMaintenance') + ->assertDispatched('success'); + + $application->refresh(); + expect($application->is_maintenance_enabled)->toBeTrue() + ->and($application->maintenance_page)->toBe('

Back soon

') + ->and(writtenProxyFile(maintenanceScriptLines(), "/data/coolify/proxy/error-pages/maintenance-{$application->uuid}.html"))->toBe('

Back soon

'); +}); + +it('rejects a maintenance page larger than the limit', function () { + $application = maintenanceApplication(); + $this->actingAs(maintenanceUser($this->team, 'owner')); + session(['currentTeam' => $this->team]); + + Livewire::test(Maintenance::class, ['resource' => $application]) + ->set('maintenancePage', str_repeat('a', Server::PROXY_ERROR_PAGE_MAX_BYTES + 1)) + ->call('submit') + ->assertDispatched('error'); + + expect($application->fresh()->maintenance_page)->toBeNull(); +}); + +it('does not let a member or another team change maintenance mode', function (string $who) { + $service = maintenanceService(['ghost' => 'https://blog.example.com']); + $team = $who === 'member' ? $this->team : Team::factory()->create(); + $this->actingAs(maintenanceUser($team, $who === 'member' ? 'member' : 'owner')); + session(['currentTeam' => $team]); + + Livewire::test(Maintenance::class, ['resource' => $service]) + ->set('isMaintenanceEnabled', true) + ->call('instantSaveMaintenance') + ->assertDispatched('error') + ->set('maintenancePage', '

Hacked

') + ->call('submit') + ->assertDispatched('error'); + + $service->refresh(); + expect($service->is_maintenance_enabled)->toBeFalse() + ->and($service->maintenance_page)->toBeNull(); + Process::assertNothingRan(); +})->with(['member', 'other team']); diff --git a/tests/Feature/Proxy/NonRootProxyCommandsTest.php b/tests/Feature/Proxy/NonRootProxyCommandsTest.php index f1cda7eb28..ea0c0d19e8 100644 --- a/tests/Feature/Proxy/NonRootProxyCommandsTest.php +++ b/tests/Feature/Proxy/NonRootProxyCommandsTest.php @@ -93,8 +93,10 @@ it('writes the Caddyfile through sudo', function () { StartProxy::run($this->server, async: false, force: true); + $base64 = base64_encode("import /dynamic/*.caddy\n"); + expect(nonRootProxyScriptLines()) - ->toContain("echo 'import /dynamic/*.caddy' | sudo tee /data/coolify/proxy/caddy/dynamic/Caddyfile > /dev/null"); + ->toContain("echo '{$base64}' | sudo base64 -d | sudo tee /data/coolify/proxy/caddy/dynamic/Caddyfile > /dev/null"); }); it('deploys the swarm proxy stack with an absolute compose file path', function () { diff --git a/tests/Feature/Proxy/ProxyErrorPageTest.php b/tests/Feature/Proxy/ProxyErrorPageTest.php new file mode 100644 index 0000000000..4d1f939459 --- /dev/null +++ b/tests/Feature/Proxy/ProxyErrorPageTest.php @@ -0,0 +1,286 @@ + Server::flushIdentityMap()); + +beforeEach(function () { + Server::flushIdentityMap(); + Event::fake([ProxyStatusChanged::class, ProxyStatusChangedUI::class]); + InstanceSettings::forceCreate(['id' => 0]); + config(['constants.ssh.mux_enabled' => false]); + fakeProxyFileUploads(); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'user' => 'cooluser', + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); +}); + +function useErrorPageProxy(Server $server, string $proxyType, array $proxy = []): Server +{ + $server->proxy->type = $proxyType; + foreach ($proxy as $key => $value) { + $server->proxy->{$key} = $value; + } + $server->save(); + + return $server->refresh(); +} + +/** + * @return array + */ +function errorPageScriptLines(): array +{ + $lines = []; + Process::assertRan(function ($process) use (&$lines) { + array_push($lines, ...explode("\n", $process->command)); + + return true; + }); + + return $lines; +} + +it('routes the Traefik catch-all through the errors middleware and keeps the 503', function () { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value); + + $config = Yaml::parse($server->defaultRedirectConfiguration()); + + expect($config['http']['routers']['catchall']['service'])->toBe('noop') + ->and($config['http']['routers']['catchall']['middlewares'])->toBe(['error-pages']) + ->and($config['http']['services']['noop']['loadBalancer']['servers'])->toBe([]) + ->and($config['http']['middlewares']['error-pages']['errors'])->toBe([ + 'status' => ['503'], + 'service' => 'error-pages', + 'query' => '/503.html', + ]) + ->and($config['http']['services']['error-pages']['loadBalancer']['servers']) + ->toBe([['url' => 'http://coolify-proxy-error-pages:80']]); +}); + +it('catches plain HTTP with a router without TLS', function (array $proxy, array $middlewares) { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value, $proxy); + + $routers = Yaml::parse($server->defaultRedirectConfiguration())['http']['routers']; + + // A router with `tls` matches only HTTPS, so plain HTTP needs the catchall-http router. + expect($routers['catchall']['entryPoints'])->toBe(['https']) + ->and($routers['catchall']['tls'])->toBe(['certResolver' => 'letsencrypt']) + ->and($routers['catchall-http'])->toBe([ + 'entryPoints' => ['http'], + 'service' => 'noop', + 'rule' => 'PathPrefix(`/`)', + 'priority' => -1000, + 'middlewares' => $middlewares, + ]); +})->with([ + 'error page' => [[], ['error-pages']], + 'redirect URL' => [['redirect_url' => 'https://example.com'], ['redirect-regexp']], +]); + +it('serves the error page through Caddy with a 503 status', function () { + $server = useErrorPageProxy($this->server, ProxyTypes::CADDY->value); + + expect($server->defaultRedirectConfiguration()) + ->toContain('reverse_proxy coolify-proxy-error-pages:80 {') + ->toContain('rewrite /503.html') + ->toContain('copy_response 503') + ->not->toContain('respond 503'); +}); + +it('keeps the redirect URL ahead of the error page', function (string $proxyType) { + $server = useErrorPageProxy($this->server, $proxyType, ['redirect_url' => 'https://example.com']); + $server->proxy_error_page = '

custom

'; + $server->save(); + + $configuration = $server->defaultRedirectConfiguration(); + $server->setupDefaultRedirect(); + $lines = errorPageScriptLines(); + + expect($configuration)->toContain('https://example.com')->not->toContain('error-pages') + ->and($lines)->toContain('sudo docker rm -f coolify-proxy-error-pages >/dev/null 2>&1 || sudo true') + ->and(implode("\n", $lines))->not->toContain('docker run'); +})->with([ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value]); + +it('removes the catch-all and the page container when the default redirect is disabled', function () { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value, ['redirect_enabled' => false]); + + $server->setupDefaultRedirect(); + $lines = errorPageScriptLines(); + + expect($lines) + ->toContain('sudo rm -f /data/coolify/proxy//dynamic/default_redirect_503.yaml') + ->toContain('sudo docker rm -f coolify-proxy-error-pages >/dev/null 2>&1 || sudo true') + ->and(writtenProxyFile($lines, '/data/coolify/proxy//dynamic/default_redirect_503.yaml'))->toBeNull(); +}); + +it('starts the page container through sudo before it writes the catch-all', function (string $proxyType, string $catchAll) { + $server = useErrorPageProxy($this->server, $proxyType); + + $server->setupDefaultRedirect(); + $lines = errorPageScriptLines(); + + $runLine = collect($lines)->search(fn (string $line) => str_contains($line, 'docker run -d --name coolify-proxy-error-pages')); + $catchAllLine = collect($lines)->search(fn (string $line) => str_contains($line, "tee $catchAll")); + + expect($runLine)->toBeInt() + ->and($catchAllLine)->toBeInt() + ->and($runLine)->toBeLessThan($catchAllLine) + ->and($lines[$runLine])->toBe('sudo docker start coolify-proxy-error-pages >/dev/null 2>&1 || sudo docker run -d --name coolify-proxy-error-pages --network coolify --restart unless-stopped --label coolify.managed=true -v /data/coolify/proxy/error-pages:/www:ro busybox:musl httpd -f -p 80 -h /www') + ->and($lines)->toContain('sudo mkdir -p /data/coolify/proxy/error-pages && sudo find /data/coolify/proxy/error-pages -user root -exec chown -h cooluser:cooluser {} + && sudo chmod o-rwx /data/coolify/proxy/error-pages') + // The page goes through scp, then sudo moves it into place. + ->and(collect($lines)->contains(fn (string $line) => preg_match('#^sudo mv -f /tmp/coolify-upload-[0-9a-f]+ /data/coolify/proxy/error-pages/503.html$#', $line) === 1))->toBeTrue() + ->and($lines)->toContain('sudo chmod 644 /data/coolify/proxy/error-pages/503.html') + ->and(writtenProxyFile($lines, '/data/coolify/proxy/error-pages/503.html'))->toContain('This site is not available at the moment') + ->and(writtenProxyFile($lines, $catchAll))->toContain('coolify-proxy-error-pages'); +})->with([ + 'traefik' => [ProxyTypes::TRAEFIK->value, '/data/coolify/proxy//dynamic/default_redirect_503.yaml'], + 'caddy' => [ProxyTypes::CADDY->value, '/data/coolify/proxy/caddy/dynamic/default_redirect_503.caddy'], +]); + +it('pushes the custom page and falls back to the default page after a reset', function () { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value); + $server->proxy_error_page = '

Maintenance

'; + $server->save(); + + $server->setupDefaultRedirect(); + expect(writtenProxyFile(errorPageScriptLines(), '/data/coolify/proxy/error-pages/503.html'))->toBe('

Maintenance

'); + + $server->proxy_error_page = null; + $server->save(); + $server->setupDefaultRedirect(); + + expect(writtenProxyFile(errorPageScriptLines(), '/data/coolify/proxy/error-pages/503.html')) + ->toContain('This site is not available at the moment') + ->toContain('https://coolify.io/docs/troubleshoot/applications/no-available-server') + ->not->toContain($server->name) + ->not->toContain($server->ip) + ->not->toContain($this->team->name); +}); + +it('keeps Swarm servers on the plain catch-all without a page container', function (string $swarmSetting) { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value); + $server->settings->update([$swarmSetting => true]); + $server->refresh(); + + $config = Yaml::parse($server->defaultRedirectConfiguration()); + $server->setupDefaultRedirect(); + + expect($config['http']['routers'])->toBe(['catchall' => [ + 'entryPoints' => ['http', 'https'], + 'service' => 'noop', + 'rule' => 'PathPrefix(`/`)', + 'tls' => ['certResolver' => 'letsencrypt'], + 'priority' => -1000, + ]]) + ->and($config['http'])->not->toHaveKey('middlewares') + ->and(array_keys($config['http']['services']))->toBe(['noop']) + ->and(implode("\n", errorPageScriptLines()))->not->toContain('coolify-proxy-error-pages'); +})->with(['is_swarm_manager', 'is_swarm_worker']); + +it('removes the page container when the proxy stops', function () { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value); + + StopProxy::run($server); + + expect(errorPageScriptLines())->toContain('sudo docker rm -f coolify-proxy-error-pages 2>/dev/null || sudo true'); +}); + +function errorPageUser(Team $team, string $role): User +{ + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => $role]); + + return $user; +} + +it('lets an admin save and reset the custom error page', function () { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value); + $this->actingAs(errorPageUser($this->team, 'admin')); + session(['currentTeam' => $this->team]); + + // The page loads the proxy configuration before the global save bar submits it. + $component = Livewire::test('server.proxy', ['server' => $server]) + ->set('proxySettings', "services:\n traefik:\n image: traefik:v3.6\n ports:\n - '80:80'\n") + ->set('customErrorPage', '

Maintenance

') + ->call('submit') + ->assertDispatched('success'); + + expect($server->fresh()->proxy_error_page)->toBe('

Maintenance

') + ->and(writtenProxyFile(errorPageScriptLines(), '/data/coolify/proxy/error-pages/503.html'))->toBe('

Maintenance

'); + + $component->call('resetCustomErrorPage') + ->assertDispatched('success') + ->assertSet('customErrorPage', null); + + expect($server->fresh()->proxy_error_page)->toBeNull(); +}); + +it('uploads a custom page larger than one shell argument', function () { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value); + $page = '

Big

'.str_repeat('a', 300 * 1024); + $server->proxy_error_page = $page; + $server->save(); + + $server->setupDefaultRedirect(); + $lines = errorPageScriptLines(); + + // Linux limits one shell argument to 128 KiB, so the page must not be inside a command. + expect(writtenProxyFile($lines, '/data/coolify/proxy/error-pages/503.html'))->toBe($page) + ->and(collect($lines)->max(fn (string $line) => strlen($line)))->toBeLessThan(128 * 1024); +}); + +it('rejects a custom error page larger than the limit', function () { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value); + $this->actingAs(errorPageUser($this->team, 'owner')); + session(['currentTeam' => $this->team]); + + Livewire::test('server.proxy', ['server' => $server]) + ->set('customErrorPage', str_repeat('a', Server::PROXY_ERROR_PAGE_MAX_BYTES + 1)) + ->call('submit') + ->assertDispatched('error'); + + expect($server->fresh()->proxy_error_page)->toBeNull(); + Process::assertNothingRan(); +}); + +it('does not let a member or another team change the custom error page', function (string $who) { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value); + $server->proxy_error_page = '

Original

'; + $server->save(); + + $team = $who === 'member' ? $this->team : Team::factory()->create(); + $this->actingAs(errorPageUser($team, $who === 'member' ? 'member' : 'owner')); + session(['currentTeam' => $team]); + + Livewire::test('server.proxy', ['server' => $server]) + ->set('customErrorPage', '

Hacked

') + ->call('submit') + ->assertDispatched('error') + ->call('resetCustomErrorPage') + ->assertDispatched('error'); + + expect($server->fresh()->proxy_error_page)->toBe('

Original

'); + Process::assertNothingRan(); +})->with(['member', 'other team']); diff --git a/tests/Feature/Security/CloudProviderTokenFormTest.php b/tests/Feature/Security/CloudProviderTokenFormTest.php index a3d06fba11..f1e6af5cde 100644 --- a/tests/Feature/Security/CloudProviderTokenFormTest.php +++ b/tests/Feature/Security/CloudProviderTokenFormTest.php @@ -60,7 +60,35 @@ test('security cloud token form lets users choose every supported provider', fun ->assertSee('Provider') ->assertSee('Hetzner') ->assertSee('DigitalOcean') - ->assertSee('Vultr'); + ->assertSee('Vultr') + ->assertSee('Hostinger'); +}); + +test('adding a hostinger token from a modal validates and refreshes hostinger token selectors', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([], 200), + ]); + + $component = Livewire::test(CloudProviderTokenForm::class, [ + 'modal_mode' => true, + 'provider' => 'hostinger', + ]) + ->set('name', 'Production Hostinger') + ->set('token', 'hostinger-token') + ->call('addToken') + ->assertHasNoErrors() + ->assertDispatched('close-modal'); + + $dispatches = collect(data_get($component->effects, 'dispatches', [])); + + expect($dispatches->contains(fn (array $dispatch) => $dispatch['name'] === 'tokenAdded.hostinger' + && data_get($dispatch, 'to') === 'server.new.by-hostinger'))->toBeTrue(); + + $this->assertDatabaseHas('cloud_provider_tokens', [ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'name' => 'Production Hostinger', + ]); }); test('adding a cloud provider token stores an optional description', function () { diff --git a/tests/Feature/Security/CloudProviderTokenShowTest.php b/tests/Feature/Security/CloudProviderTokenShowTest.php index c3a1ce082e..822b242f64 100644 --- a/tests/Feature/Security/CloudProviderTokenShowTest.php +++ b/tests/Feature/Security/CloudProviderTokenShowTest.php @@ -94,6 +94,21 @@ test('cloud token detail page validates the token', function () { ->assertDispatched('success'); }); +test('cloud token detail page validates Hostinger tokens', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([], 200), + ]); + + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + ]); + + Livewire::test(Show::class, ['cloud_token_uuid' => $token->uuid]) + ->call('validateToken') + ->assertDispatched('success'); +}); + test('cloud token detail page deletes unused tokens', function () { $token = CloudProviderToken::factory()->create([ 'team_id' => $this->team->id, diff --git a/tests/Feature/ServerCloudProviderStatusCheckJobTest.php b/tests/Feature/ServerCloudProviderStatusCheckJobTest.php index 10e1a8c9d8..ba0ceee459 100644 --- a/tests/Feature/ServerCloudProviderStatusCheckJobTest.php +++ b/tests/Feature/ServerCloudProviderStatusCheckJobTest.php @@ -60,6 +60,36 @@ it('syncs provider state for a placeholder server without scheduling SSH', funct Queue::assertNotPushed(ServerConnectionCheckJob::class); }); +it('schedules Hostinger provider state checks', function () { + $server = createCloudServerForStatusCheckJobTest('hostinger', [ + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'creating', + ]); + + Queue::fake(); + (new ServerManagerJob)->handle(); + Queue::assertPushed(ServerCloudProviderStatusCheckJob::class, fn (ServerCloudProviderStatusCheckJob $job) => $job->server->is($server)); +}); + +it('runs Hostinger provider state checks', function () { + $server = createCloudServerForStatusCheckJobTest('hostinger', [ + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'creating', + ]); + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'state' => 'running', + 'ipv4' => [['address' => '203.0.113.23']], + ]), + ]); + + (new ServerCloudProviderStatusCheckJob($server))->handle(); + + expect($server->fresh()->ip)->toBe('203.0.113.23') + ->and($server->fresh()->hostinger_virtual_machine_status)->toBe('running'); +}); + it('backfills a DigitalOcean placeholder without changing reachability', function () { $server = createCloudServerForStatusCheckJobTest('digitalocean', [ 'digitalocean_droplet_id' => 987, diff --git a/tests/Pest.php b/tests/Pest.php index 07cfd25a16..20645621ce 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,6 +1,8 @@ command, $matches)) { + $GLOBALS['proxyFileUploads'][$matches[2]] = file_get_contents($matches[1]); + } + + return Process::result(); + }); +} + +/** + * The last content written to $file on the server, with Server::uploadProxyFile() or with + * `echo '' | base64 -d | tee $file`. + * + * @param array $lines remote script lines (see Process::assertRan) + */ +function writtenProxyFile(array $lines, string $file): ?string +{ + $content = null; + foreach ($lines as $line) { + if (preg_match('#mv -f (/tmp/coolify-upload-[0-9a-f]+) '.preg_quote($file, '#').'$#', $line, $matches)) { + $content = $GLOBALS['proxyFileUploads'][$matches[1]] ?? null; + } elseif (str_contains($line, "tee $file") && preg_match("/echo '([A-Za-z0-9+\/=]+)'/", $line, $matches)) { + $content = base64_decode($matches[1]); + } + } + + return $content; +} diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php index 168e8be881..0ba3605888 100644 --- a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -260,6 +260,23 @@ it('pins docker config while running railpack buildx commands', function () { ->toContain('DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack'); }); +it('stops the railpack builder after the build when no other build uses it', function () { + [$job, $reflection] = makeRailpackDeploymentJob([ + 'uuid' => 'application-uuid', + ]); + + $command = invokeRailpackMethod($job, $reflection, 'railpack_build_command', ['coollabsio/coolify:test', collect([])]); + + $sharedLock = strpos($command, 'flock -s 9'); + $build = strpos($command, 'docker buildx build --builder coolify-railpack'); + $stop = strpos($command, 'if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack'); + + expect($sharedLock)->not->toBeFalse() + ->and($build)->toBeGreaterThan($sharedLock) + ->and($stop)->toBeGreaterThan($build) + ->and($command)->toEndWith('exit $status'); +}); + it('filters reserved docker client variables from railpack build secrets', function () { [$job, $reflection] = makeRailpackDeploymentJob([ 'uuid' => 'application-uuid', diff --git a/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php index 1dda8b8c3e..0b7d5406c9 100644 --- a/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php @@ -81,13 +81,13 @@ it('generates escaped railpack env args from resolved values and includes instal 'RAILPACK_CUSTOM_FLAG' => 'hello world', 'RAILPACK_JSON' => '{"token":"abc"}', 'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall', - 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget', + 'RAILPACK_DEPLOY_APT_PACKAGES' => '... curl wget', ]); expect($envArgs)->toContain("--env 'RAILPACK_NODE_VERSION=22'"); expect($envArgs)->toContain("--env 'RAILPACK_CUSTOM_FLAG=hello world'"); expect($envArgs)->toContain("--env 'RAILPACK_JSON={\"token\":\"abc\"}'"); expect($envArgs)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'"); - expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'"); + expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=... curl wget'"); expect($envArgs)->not->toContain('RAILPACK_NULL'); }); @@ -136,7 +136,7 @@ it('uses preview railpack environment variables for preview deployments', functi expect($variables->all())->toBe([ 'RAILPACK_PREVIEW_ONLY' => 'preview-value', - 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget', + 'RAILPACK_DEPLOY_APT_PACKAGES' => '... curl wget', ]); }); @@ -195,7 +195,7 @@ it('merges coolify env variables into railpack build variables', function () { expect($variables->all())->toBe([ 'MY_BUILD_VAR' => 'hello', - 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget', + 'RAILPACK_DEPLOY_APT_PACKAGES' => '... curl wget', 'COOLIFY_URL' => 'https://app.example.com', 'COOLIFY_FQDN' => 'app.example.com', 'COOLIFY_BRANCH' => 'main', @@ -209,7 +209,7 @@ it('merges coolify env variables into railpack build variables', function () { expect($envArgs)->toContain("--env 'COOLIFY_URL=https://app.example.com'"); expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'"); - expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'"); + expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=... curl wget'"); expect($envArgs)->not->toContain('EMPTY_VAR'); expect($envArgs)->not->toContain('NULL_VAR'); }); @@ -257,11 +257,11 @@ it('preserves user railpack deploy apt packages while adding healthcheck tools o $method->setAccessible(true); $variables = $method->invoke($job); - expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('ffmpeg curl wget'); + expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... ffmpeg curl wget'); $envArgsProperty = $reflection->getProperty('env_railpack_args'); $envArgsProperty->setAccessible(true); $envArgs = $envArgsProperty->getValue($job); - expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=ffmpeg curl wget'"); + expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=... ffmpeg curl wget'"); }); diff --git a/tests/Unit/CloudProviderTokenFormViewTest.php b/tests/Unit/CloudProviderTokenFormViewTest.php index 58e0e60777..f5666a0b39 100644 --- a/tests/Unit/CloudProviderTokenFormViewTest.php +++ b/tests/Unit/CloudProviderTokenFormViewTest.php @@ -1,5 +1,47 @@ toContain('and($view)->toContain("['value' => 'vultr', 'label' => 'Vultr']") + ->and($view)->toContain("['value' => 'digitalocean', 'label' => 'DigitalOcean']") + ->and($view)->toContain("['value' => 'hostinger', 'label' => 'Hostinger']") + ->and(substr_count($view, 'https://console.vultr.com/user/apiaccess/'))->toBe(1) + ->and($view)->not->toContain('cloudProviderTokens->where(\'provider\', $provider)->isEmpty()') + ->and($view)->not->toContain('') + ->and($component)->toContain("'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger'"); +}); + +it('keeps provider affiliate links on server provider views', function () { + $tokenFormView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php'); + $hetznerView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-hetzner.blade.php'); + $vultrView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-vultr.blade.php'); + $digitalOceanView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-digital-ocean.blade.php'); + + expect($tokenFormView) + ->not->toContain('https://coolify.io/hetzner') + ->not->toContain('https://coolify.io/vultr') + ->and($hetznerView)->toContain('https://coolify.io/hetzner') + ->and($vultrView)->toContain('https://coolify.io/vultr') + ->and($digitalOceanView)->toContain('https://coolify.io/digitalocean'); +}); + +it('uses the shared modal form on the cloud provider tokens page', function () { + $view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-tokens.blade.php'); + + expect($view)->toContain('New token') + ->and($view)->toContain('and($view)->toContain('and($view)->toContain("\$savedToken->provider === 'digitalocean' ? 'DigitalOcean' : ucfirst(\$savedToken->provider)") + ->and($view)->not->toContain('

New Token

') + ->and($view)->not->toContain(':modal_mode="false"') + ->and($view)->not->toContain('wire:click="validateToken') + ->and($view)->not->toContain('submitAction="deleteToken') + ->and($view)->not->toContain('Created {{ $savedToken->created_at->diffForHumans() }}'); +}); + it('gates new cloud tokens and keys saved token rows by id', function () { $view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-tokens.blade.php'); diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index 1e2e0d796c..f4b4586832 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -148,6 +148,22 @@ it('detects deployment hook changes as redeploy-only changes', function (string 'post-deployment container' => ['post_deployment_command_container', 'Post-deployment command container', 'worker'], ]); +it('detects HTTP basic auth hash setting changes as redeploy-only changes', function (string $field, string $label, string|int $newValue) { + $application = snapshotTestApplication(); + markSnapshotTestApplicationDeployed($application); + + $application->update([$field => $newValue]); + $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); + + expect($diff->requiresBuild())->toBeFalse() + ->and(collect($diff->changes())->pluck('label'))->toContain($label); +})->with([ + 'hash algorithm' => ['http_basic_auth_hash_algorithm', 'HTTP basic auth hash algorithm', 'argon2id'], + 'bcrypt cost factor' => ['http_basic_auth_bcrypt_cost', 'HTTP basic auth bcrypt cost factor', 4], + 'Argon2id memory' => ['http_basic_auth_argon2id_memory_cost', 'HTTP basic auth Argon2id memory (KiB)', 8192], + 'Argon2id iterations' => ['http_basic_auth_argon2id_time_cost', 'HTTP basic auth Argon2id iterations', 2], +]); + it('detects source integration changes as build changes', function (string $field, string $label, mixed $newValue) { $application = snapshotTestApplication([ 'source_id' => 1, diff --git a/tests/Unit/HostingerDeleteServerTest.php b/tests/Unit/HostingerDeleteServerTest.php new file mode 100644 index 0000000000..ee74c1fefd --- /dev/null +++ b/tests/Unit/HostingerDeleteServerTest.php @@ -0,0 +1,90 @@ +team = Team::factory()->create(); + $this->token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'test-hostinger-token', + ]); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + ]); + $this->server->delete(); +}); + +it('deletes the Hostinger VPS by disabling auto-renewal when requested', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'subscription_id' => 'Azz2Y9VWEjUO69lF', + ]), + 'https://developers.hostinger.com/api/billing/v1/subscriptions/Azz2Y9VWEjUO69lF/auto-renewal/disable' => Http::response([ + 'id' => 'Azz2Y9VWEjUO69lF', + 'is_auto_renewed' => false, + ]), + ]); + + DeleteServer::run( + serverId: $this->server->id, + cloudProviderTokenId: $this->token->id, + teamId: $this->team->id, + deleteFromHostinger: true, + hostingerVirtualMachineId: 17923, + ); + + Http::assertSent(fn ($request) => $request->method() === 'DELETE' + && $request->url() === 'https://developers.hostinger.com/api/billing/v1/subscriptions/Azz2Y9VWEjUO69lF/auto-renewal/disable'); + expect(Server::withTrashed()->find($this->server->id))->toBeNull(); +}); + +it('retains the server when the Hostinger VPS cannot be deleted', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'subscription_id' => 'Azz2Y9VWEjUO69lF', + ]), + 'https://developers.hostinger.com/api/billing/v1/subscriptions/Azz2Y9VWEjUO69lF/auto-renewal/disable' => Http::response([ + 'message' => 'Unauthorized', + ], 403), + ]); + + expect(fn () => DeleteServer::run( + serverId: $this->server->id, + cloudProviderTokenId: $this->token->id, + teamId: $this->team->id, + deleteFromHostinger: true, + hostingerVirtualMachineId: 17923, + ))->toThrow(Exception::class, 'Hostinger API error: Unauthorized'); + + expect(Server::withTrashed()->find($this->server->id))->not->toBeNull(); +}); + +it('does not call Hostinger when the VPS should be kept', function () { + DeleteServer::run( + serverId: $this->server->id, + cloudProviderTokenId: $this->token->id, + teamId: $this->team->id, + hostingerVirtualMachineId: 17923, + ); + + Http::assertNothingSent(); + expect(Server::withTrashed()->find($this->server->id))->toBeNull(); +}); diff --git a/tests/Unit/HostingerIconTest.php b/tests/Unit/HostingerIconTest.php new file mode 100644 index 0000000000..bbb84c6fb9 --- /dev/null +++ b/tests/Unit/HostingerIconTest.php @@ -0,0 +1,10 @@ +toContain('viewBox="0 0 26 30"') + ->toContain('M0.000249566 14.046V0.000497794') + ->toContain('Hostinger'); +}); diff --git a/tests/Unit/HostingerServerStateTest.php b/tests/Unit/HostingerServerStateTest.php new file mode 100644 index 0000000000..c4073e2dbb --- /dev/null +++ b/tests/Unit/HostingerServerStateTest.php @@ -0,0 +1,45 @@ +create(); + $token = CloudProviderToken::factory()->create([ + 'team_id' => $team->id, + 'provider' => 'hostinger', + 'token' => 'hostinger-token', + ]); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'creating', + 'ip' => '0.0.0.0', + ]); + + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'state' => 'running', + 'ipv4' => [['address' => '203.0.113.10']], + ]), + ]); + + expect($server->refreshHostingerState())->toBe('running') + ->and($server->fresh()->hostinger_virtual_machine_status)->toBe('running') + ->and($server->fresh()->ip)->toBe('203.0.113.10'); +}); diff --git a/tests/Unit/HostingerServiceTest.php b/tests/Unit/HostingerServiceTest.php new file mode 100644 index 0000000000..15cb16a422 --- /dev/null +++ b/tests/Unit/HostingerServiceTest.php @@ -0,0 +1,339 @@ + Http::response([ + ['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam', 'location' => 'nl'], + ]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + ['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'], + ]), + 'https://developers.hostinger.com/api/billing/v1/catalog?category=VPS' => Http::response([ + [ + 'id' => 'hostingercom-vps-kvm2', + 'name' => 'KVM 2', + 'category' => 'VPS', + 'prices' => [ + [ + 'id' => 'hostingercom-vps-kvm2-usd-1m', + 'currency' => 'USD', + 'price' => 1799, + 'first_period_price' => 899, + 'period' => 1, + 'period_unit' => 'month', + ], + ], + ], + ]), + ]); + + $service = new HostingerService('test-token'); + + expect($service->getDataCenters()[0]['city'])->toBe('Amsterdam') + ->and($service->getTemplates()[0]['name'])->toBe('Ubuntu 24.04 LTS') + ->and($service->getCatalogItems()[0]['prices'][0]['price'])->toBe(1799); +}); + +it('returns only KVM plans from the Hostinger VPS catalog', function () { + Http::fake([ + 'https://developers.hostinger.com/api/billing/v1/catalog?category=VPS' => Http::response([ + ['id' => 'hostingercom-vps-kvm1', 'name' => 'KVM 1', 'prices' => []], + ['id' => 'hostingercom-vps-kvmminecraftalex', 'name' => 'Game Panel 1', 'prices' => []], + ['id' => 'hostingercom-vps-kvm8', 'name' => 'KVM 8', 'prices' => []], + ['id' => 'hostingercom-vps-kvmminecraftwolf', 'name' => 'Game Panel 8', 'prices' => []], + ['name' => 'Missing id', 'prices' => []], + ]), + ]); + + expect(collect((new HostingerService('test-token'))->getCatalogItems())->pluck('id')->all()) + ->toBe(['hostingercom-vps-kvm1', 'hostingercom-vps-kvm8']); +}); + +it('fetches and attaches Hostinger account SSH keys', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response([ + 'data' => [['id' => 42, 'name' => 'Operations', 'key' => 'ssh-ed25519 AAAA']], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys/attach/17923' => Http::response([ + 'id' => 456, + 'state' => 'running', + ]), + ]); + + $service = new HostingerService('test-token'); + + expect($service->getPublicKeys()[0]['id'])->toBe(42) + ->and($service->attachPublicKeys(17923, ['42'])['id'])->toBe(456); + + Http::assertSent(fn ($request) => $request->url() === 'https://developers.hostinger.com/api/vps/v1/public-keys/attach/17923' + && $request['ids'] === [42]); +}); + +it('fetches Hostinger post-install scripts', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response([ + 'data' => [['id' => 73, 'name' => 'Bootstrap Coolify']], + ]), + ]); + + expect((new HostingerService('test-token'))->getPostInstallScripts()[0]['id'])->toBe(73); +}); + +it('purchases a Hostinger virtual machine with its setup options', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'order' => ['id' => 2957086, 'status' => 'completed'], + 'virtual_machine' => [ + 'id' => 17923, + 'hostname' => 'coolify-test.example.com', + 'state' => 'creating', + 'ipv4' => [['address' => '203.0.113.10']], + ], + ]), + ]); + + $virtualMachine = (new HostingerService('test-token'))->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm2-usd-1m', + 'setup' => [ + 'data_center_id' => 19, + 'template_id' => 1130, + 'hostname' => 'coolify-test.example.com', + 'enable_backups' => true, + 'public_key' => [ + 'name' => 'Coolify', + 'key' => 'ssh-ed25519 AAAA test@example.com', + ], + ], + ]); + + expect($virtualMachine['id'])->toBe(17923); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request->hasHeader('Authorization', 'Bearer test-token') + && $request['item_id'] === 'hostingercom-vps-kvm2-usd-1m' + && $request['setup']['data_center_id'] === 19 + && $request['setup']['template_id'] === 1130 + && $request['setup']['enable_backups'] === true + && $request['setup']['public_key']['key'] === 'ssh-ed25519 AAAA test@example.com'); +}); + +it('only sends a fully qualified hostname to Hostinger', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => ['id' => 17923, 'state' => 'creating'], + ]), + ]); + + $service = new HostingerService('test-token'); + $service->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1130, 'hostname' => 'coolify-server'], + ]); + $service->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1130, 'hostname' => 'coolify.example.com'], + ]); + + $sentHostnames = Http::recorded()->filter(fn ($record) => $record[0]->method() === 'POST')->values()->map(fn ($record) => $record[0]['setup']['hostname'] ?? null)->all(); + + expect($sentHostnames)->toBe([null, 'coolify.example.com']); +}); + +it('does not retry a failed Hostinger purchase', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => fn ($request) => $request->method() === 'GET' + ? Http::response([]) + : Http::response(['message' => 'Server error'], 500), + ]); + + expect(fn () => (new HostingerService('test-token'))->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm2-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1130], + ]))->toThrow(Exception::class, 'Hostinger API error: Server error'); + + Http::assertSentCount(3); + expect(Http::recorded()->filter(fn ($record) => $record[0]->method() === 'POST'))->toHaveCount(1); +}); + +it('sets up a charged Hostinger VPS when the purchase returns an error', function () { + $listRequests = 0; + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/2008976/setup' => Http::response([ + 'id' => 2008976, + 'state' => 'creating', + 'ipv4' => [['address' => '72.61.180.163']], + ]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => function ($request) use (&$listRequests) { + if ($request->method() === 'POST') { + return Http::response(['message' => '[VPS:2004] Wrong hostname FQDN format'], 422); + } + + return Http::response($listRequests++ === 0 + ? [['id' => 100, 'state' => 'running']] + : [['id' => 100, 'state' => 'running'], ['id' => 2008976, 'state' => 'initial']]); + }, + ]); + + $virtualMachine = (new HostingerService('test-token'))->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1077, 'enable_backups' => false], + ]); + + expect($virtualMachine['id'])->toBe(2008976) + ->and($virtualMachine['state'])->toBe('creating'); + + Http::assertSent(fn ($request) => $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines/2008976/setup' + && $request['data_center_id'] === 19 + && $request['template_id'] === 1077 + && $request['enable_backups'] === false); +}); + +it('returns a charged Hostinger VPS that still needs setup when the setup retry fails', function () { + $listRequests = 0; + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/2008976/setup' => Http::response(['message' => 'Setup failed'], 422), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => function ($request) use (&$listRequests) { + if ($request->method() === 'POST') { + return Http::response(['message' => 'Setup failed'], 422); + } + + return Http::response($listRequests++ === 0 ? [] : [['id' => 2008976, 'state' => 'initial']]); + }, + ]); + + $virtualMachine = (new HostingerService('test-token'))->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1077], + ]); + + expect($virtualMachine)->toBe(['id' => 2008976, 'state' => 'initial']); +}); + +it('reports a Hostinger purchase whose payment is still processing', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'id' => 2957086, + 'subscription_id' => 'Azz353Uhl1xC54pR0', + 'status' => 'payment_initiated', + 'message' => 'Payment is being processed.', + ], 202), + ]); + Log::shouldReceive('warning')->once()->with('Hostinger VPS order did not return a virtual machine', [ + 'id' => 2957086, + 'subscription_id' => 'Azz353Uhl1xC54pR0', + 'status' => 'payment_initiated', + 'message' => 'Payment is being processed.', + ]); + + expect(fn () => (new HostingerService('test-token'))->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm2-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1130], + ]))->toThrow(Exception::class, 'Hostinger order 2957086 is waiting for payment. Finish the VPS setup in hPanel.'); +}); + +it('extracts a Hostinger public IPv4 address before falling back to IPv6', function () { + $virtualMachine = [ + 'ipv4' => [['address' => '203.0.113.10']], + 'ipv6' => [['address' => '2001:db8::10']], + ]; + + $service = new HostingerService('test-token'); + + expect($service->getPublicIpAddress($virtualMachine))->toBe('203.0.113.10') + ->and($service->getPublicIpAddress(['ipv4' => [], 'ipv6' => $virtualMachine['ipv6']]))->toBe('2001:db8::10'); +}); + +it('waits for Hostinger to assign a public IP', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'state' => 'running', + 'ipv4' => [['address' => '203.0.113.10']], + ]), + ]); + + $service = new HostingerService('test-token'); + $virtualMachine = $service->waitForPublicIp([ + 'id' => 17923, + 'state' => 'creating', + 'ipv4' => [], + ], sleepMilliseconds: 0); + + expect($service->getPublicIpAddress($virtualMachine))->toBe('203.0.113.10'); +}); + +it('stops waiting for a Hostinger public IP after 10 attempts by default', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'state' => 'initial', + 'ipv4' => [], + ]), + ]); + + $service = new HostingerService('test-token'); + $virtualMachine = $service->waitForPublicIp([ + 'id' => 17923, + 'state' => 'initial', + 'ipv4' => [], + ], sleepMilliseconds: 0); + + expect($service->getPublicIpAddress($virtualMachine))->toBeNull(); + Http::assertSentCount(10); +}); + +it('finds a Hostinger virtual machine by public IP', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + [ + 'id' => 17923, + 'ipv4' => [['address' => '203.0.113.10']], + 'ipv6' => [['address' => '2001:db8::10']], + ], + ]), + ]); + + $service = new HostingerService('test-token'); + + expect($service->findVirtualMachineByIp('203.0.113.10')['id'])->toBe(17923) + ->and($service->findVirtualMachineByIp('2001:db8::10')['id'])->toBe(17923) + ->and($service->findVirtualMachineByIp('198.51.100.1'))->toBeNull(); +}); + +it('starts a Hostinger virtual machine', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923/start' => Http::response([ + 'id' => 456, + 'name' => 'start', + 'state' => 'running', + ]), + ]); + + $action = (new HostingerService('test-token'))->startVirtualMachine(17923); + + expect($action['state'])->toBe('running'); +}); + +it('raises the shared rate limit exception for Hostinger throttling', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + 'message' => 'Too many requests.', + ], 429, ['Retry-After' => '30']), + ]); + + expect(fn () => (new HostingerService('test-token'))->getDataCenters()) + ->toThrow(RateLimitException::class, 'Rate limit exceeded. Please try again later.'); +}); diff --git a/tests/Unit/ParseCommandsByLineForSudoTest.php b/tests/Unit/ParseCommandsByLineForSudoTest.php index 594ecbd046..a0ef50d1ad 100644 --- a/tests/Unit/ParseCommandsByLineForSudoTest.php +++ b/tests/Unit/ParseCommandsByLineForSudoTest.php @@ -863,3 +863,17 @@ test('parseCommandsByLineForSudo keeps pipes inside quoted strings unchanged', f expect($result[0])->toBe("sudo docker ps --format '{{.Names}} | {{.Image}}' | sudo grep app"); }); + +test('railpack builder helper command gets sudo only outside the helper script', function () { + $command = railpackBuilderHelperCommand('$HOME/.docker/buildx', 'coolify-helper:1.0.0', 'docker buildx prune --builder coolify-railpack -af'); + + $parsed = parseCommandsByLineForSudo(collect([$command]), $this->server)[0]; + + // The SSH user's shell must expand $HOME, so the line must not be wrapped in `sudo bash -c`. + expect($parsed)->toBe( + 'sudo docker run --rm -v $HOME/.docker/buildx:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock coolify-helper:1.0.0' + ." bash -c 'exec 9>/root/.docker/buildx/coolify-railpack.lock; if flock -s 9; then :; else exit $?; fi; docker buildx prune --builder coolify-railpack -af; status=$?;" + .' if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack >/dev/null 2>&1; fi;' + ." exit \$status' 2>/dev/null || sudo true" + ); +}); diff --git a/tests/Unit/RailpackBuilderLockTest.php b/tests/Unit/RailpackBuilderLockTest.php new file mode 100644 index 0000000000..273104d6c4 --- /dev/null +++ b/tests/Unit/RailpackBuilderLockTest.php @@ -0,0 +1,43 @@ +run(); + + return $process; + } finally { + @unlink($directory.'/builder.lock'); + rmdir($directory); + } +} + +test('a failed shared lock prevents the builder command from running', function () { + $process = runRailpackLockScript('printf build-ran', 'flock() { return 73; }; '); + + expect($process->getExitCode())->toBe(73); + expect($process->getOutput())->toBe(''); +}); + +test('the builder stops after a failed command without hiding its exit status', function () { + $process = runRailpackLockScript('(exit 42)', 'exec 3>&1; docker() { printf stopped >&3; return 0; }; '); + + expect($process->getExitCode())->toBe(42); + expect($process->getOutput())->toBe('stopped'); +}); + +test('a held shared lock prevents stopping the builder', function () { + $process = runRailpackLockScript('printf built', 'exec 8>/root/.docker/buildx/coolify-railpack.lock; flock -s 8; docker() { exit 99; }; '); + + expect($process->getExitCode())->toBe(0); + expect($process->getOutput())->toBe('built'); +}); diff --git a/tests/v4/Browser/ApplicationHttpBasicAuthHashingTest.php b/tests/v4/Browser/ApplicationHttpBasicAuthHashingTest.php new file mode 100644 index 0000000000..148900b144 --- /dev/null +++ b/tests/v4/Browser/ApplicationHttpBasicAuthHashingTest.php @@ -0,0 +1,45 @@ + Server::flushIdentityMap()); + +afterEach(fn () => Server::flushIdentityMap()); + +it('replaces the cost factor with memory and iterations when Argon2id is selected', function () { + $stack = seedBrowserResourceStack(); + $stack['server']->forceFill(['proxy' => [ + 'type' => ProxyTypes::CADDY->value, + 'status' => 'running', + 'last_saved_settings' => 'applied', + 'last_applied_settings' => 'applied', + 'last_saved_proxy_configuration' => "services:\n caddy:\n image: 'lucaslorentz/caddy-docker-proxy:2.13-alpine'\n", + ]])->save(); + $application = createBrowserApplication($stack, [ + 'name' => 'Basic Auth App', + 'fqdn' => 'https://basic-auth.example.com', + 'is_http_basic_auth_enabled' => true, + 'http_basic_auth_username' => 'admin', + 'http_basic_auth_password' => 'secret', + ]); + loginAndSkipBoarding(); + + $page = visit(applicationConfigurationUrl($stack['project'], $stack['environment'], $application)); + $page->assertSee('Cost factor') + ->script("document.querySelector('#security-section').scrollIntoView()"); + + selectListboxOption($page, 'httpBasicAuthHashAlgorithm', 'Argon2id Memory-hard, more resistant to cracking'); + + $page->assertSeeIn('#httpBasicAuthArgon2idMemoryCost-trigger', '64 MiB') + ->assertSeeIn('#httpBasicAuthArgon2idTimeCost-trigger', '4') + ->assertDontSee('Cost factor') + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'application-basic-auth-argon2id'); + + expect($application->fresh()->http_basic_auth_hash_algorithm)->toBe(HttpBasicAuthHashAlgorithm::ARGON2ID); +}); diff --git a/versions.json b/versions.json index bb76d98724..40f297c2a1 100644 --- a/versions.json +++ b/versions.json @@ -7,7 +7,7 @@ "version": "4.5-rc.1" }, "helper": { - "version": "1.0.17" + "version": "1.0.18" }, "sentinel": { "version": "1.0.2" diff --git a/vet.json b/vet.json new file mode 100644 index 0000000000..a6e2e321e4 --- /dev/null +++ b/vet.json @@ -0,0 +1,991 @@ +{ + "require": { + "aws/aws-crt-php": { + "version": "v1.2.7", + "hash": "tree-v2:be22a57eb742622a1ffba6ca86d121622eb74e75f927a8494532d5d4803badce" + }, + "aws/aws-sdk-php": { + "version": "3.398.4", + "hash": "tree-v2:6c1c6ac6e6c2d9508b01e8ca3953322575e866aa3e1487a7aa315e7dfb4ba2b8" + }, + "bacon/bacon-qr-code": { + "version": "v3.1.1", + "hash": "tree-v2:12897f0a1a47ba843a8c4dfa9cd986bc3d7455929b42b7005d030fdd778dc72a" + }, + "brick/math": { + "version": "0.14.8", + "hash": "tree-v2:79827d9b803e1ecdc82b596e792180dd785719f5d865f59a42aabbe61d85d4fb" + }, + "carbonphp/carbon-doctrine-types": { + "version": "3.2.1", + "hash": "tree-v2:0f158f3b909fc01e691ed5f5121186056232b049031e7d3a914676d49881ece5" + }, + "clue/redis-protocol": { + "version": "v0.3.2", + "hash": "tree-v2:8e03c9d35ac2bf5be5ceab4f331d9c122d99273da4f7c428d1c02ceb897ab68a" + }, + "clue/redis-react": { + "version": "v2.8.0", + "hash": "tree-v2:919b5f4e495641eb54043ec944915a2a83b269412e2acc85a18eadacc96ee1b8" + }, + "danharrin/livewire-rate-limiting": { + "version": "v2.3.0", + "hash": "tree-v2:493b9709f201171bdf9fdee2e33e3526e11a0600bd81c0ad08afa19c23703f21" + }, + "dasprid/enum": { + "version": "1.0.7", + "hash": "tree-v2:82016cbedde18f90fb0d3314cfcce52943053eca20269166154b6bc2d79e7940" + }, + "dflydev/dot-access-data": { + "version": "v3.0.3", + "hash": "tree-v2:05d15a85340adeabba2a95b5280336f0df3a544fcd89be9b2053e410212960b5" + }, + "doctrine/dbal": { + "version": "4.5.0", + "hash": "tree-v2:20df4d8e38810d489e6b77be95cdba556096476d0cf9afc00c74641b2ebca8ab" + }, + "doctrine/deprecations": { + "version": "1.1.6", + "hash": "tree-v2:8abdb22c79e82b9ea4884ab18dbaa595bb5315736231fa9e310b9d70a3ab0815" + }, + "doctrine/inflector": { + "version": "2.1.0", + "hash": "tree-v2:220a8be0ed1b4c0fe4cb53530cec8bcbad2db49962deef2f9f5de47327d7855e" + }, + "doctrine/lexer": { + "version": "3.0.2", + "hash": "tree-v2:7dabe75f6855468becf3d5a9c0ed9fdbf96f66af9776261cd2fdbeb4079f93e2" + }, + "dragonmantank/cron-expression": { + "version": "v3.6.0", + "hash": "tree-v2:de03874981dc12b8d6108e201130ed10c174926aad4e4fc0cf4362a1e6f83cb7" + }, + "egulias/email-validator": { + "version": "4.0.4", + "hash": "tree-v2:7621e009898aea85dfa2ad91d25a3441db9ef5bf90a1b55e9b8396b3a43830f9" + }, + "evenement/evenement": { + "version": "v3.0.2", + "hash": "tree-v2:5bf08ea1a28ca2559ae1b79a9e99cb3bfd5c33cf13a4b8ce7cc943461473e4e3" + }, + "ezyang/htmlpurifier": { + "version": "v4.19.1", + "hash": "tree-v2:fa90f54e49e566edcd7862886eb05a7712fda460a08937961defd674a4349a79" + }, + "firebase/php-jwt": { + "version": "v7.2.1", + "hash": "tree-v2:270603953b72218f417df9fa995b4d833b712b3a2a0d19d1d83f6e5c5ffa7875" + }, + "fruitcake/php-cors": { + "version": "v1.4.0", + "hash": "tree-v2:843a0fd0c0519e1e8e46ce45b613feb881065e0a5687b359660900b7be3d0094" + }, + "graham-campbell/result-type": { + "version": "v1.2.0", + "hash": "tree-v2:19182f95f7c4750192e6cfe25b1f769686638fc59e5517710472b9f2b68315e7" + }, + "guzzlehttp/guzzle": { + "version": "7.15.5", + "hash": "tree-v2:52f341c0bf614afe1fbd26362999adb2a746dd90cc5fc1f7a610f923b1b670a0" + }, + "guzzlehttp/promises": { + "version": "2.5.3", + "hash": "tree-v2:fd057dc4b4c991c686151e9e6cb938c9c429c389becaa8d0607a4ce15a804f10" + }, + "guzzlehttp/psr7": { + "version": "2.13.1", + "hash": "tree-v2:7607132af9efdbe31c5a6d8b92d4cac549bb4e4141caec6b0d917e813c201a19" + }, + "guzzlehttp/uri-template": { + "version": "v1.0.11", + "hash": "tree-v2:8621308a489e67328265553e68e5bb49c7e887ecb33627e108bf21f59d56d445" + }, + "jean85/pretty-package-versions": { + "version": "2.1.1", + "hash": "tree-v2:5cc304e8a530449d8c745eaec902384554d1740d4e083032d5f9ce3ac1cf25e3" + }, + "laravel/fortify": { + "version": "v1.40.0", + "hash": "tree-v2:be1c2def5d70383bc18e9893861ecb3f4c0418961dc381549b583a187e6b8d33" + }, + "laravel/framework": { + "version": "v12.69.3", + "hash": "tree-v2:406d3fd2a8e434af41c60567dc3e57830cac780dab9753e854c4ccdaab71f3a7" + }, + "laravel/horizon": { + "version": "v5.50.0", + "hash": "tree-v2:524ba1806df3f59f4daafd5db7e6c7a3767a1297396303b14dc11f129f73c4eb" + }, + "laravel/mcp": { + "version": "v1.0.1", + "hash": "tree-v2:9c474c4e3f7a9e35fd7d2ec2ffb24e02ac5d185328243fda4ff9cba7903dac70" + }, + "laravel/pail": { + "version": "v1.2.7", + "hash": "tree-v2:6ffc8a88f56b9b5082de7e1d3c620a64fa6224f3d2ccd94c23b7b73ec903929b" + }, + "laravel/passkeys": { + "version": "v0.2.1", + "hash": "tree-v2:788db1215e7e8c6cb282ee115d4b7570a7f30eb763a53b40417df4b7c5a4981c" + }, + "laravel/prompts": { + "version": "v0.3.24", + "hash": "tree-v2:af7fccf97985aef4aad89febec94cee2dce8d76a74b961c1d03a680df881ef6e" + }, + "laravel/reverb": { + "version": "v1.12.0", + "hash": "tree-v2:ccc8b90a2d1039edd725f432f001e6001a87796858f127609d1686d06921816f" + }, + "laravel/sanctum": { + "version": "v4.3.3", + "hash": "tree-v2:63830d35780274f01c5f4999126781fb3de75408055c7ec4aac8bdf2d432c314" + }, + "laravel/sentinel": { + "version": "v1.1.0", + "hash": "tree-v2:2893786cf018af94219a831c0e4056110487dd448a8dd32f80c936760a180115" + }, + "laravel/serializable-closure": { + "version": "v2.1.0", + "hash": "tree-v2:46e235154eb6a796d4dc4365dc4c2fd1bc4abb91f2235b7741061116a06e0637" + }, + "laravel/socialite": { + "version": "v5.30.0", + "hash": "tree-v2:935f438d7fd622b867b4752ae68b906f120cd770c126d4c2f6dbfc49eaec4dbb" + }, + "laravel/tinker": { + "version": "v3.0.2", + "hash": "tree-v2:07c01215fec4bef8c1a223bba86119b769ab70dd45d7013834d3dfcdf8046fea" + }, + "laravel/ui": { + "version": "v4.6.3", + "hash": "tree-v2:cdc10049a3e3b3eefa2a8f8da8afa60c1052b33d96338c7c802481aea0d3896c" + }, + "lcobucci/jwt": { + "version": "5.6.0", + "hash": "tree-v2:7374370ed18d7728465afcdcdbede894a4916ff0aea1fac63d2ae85859f74d66" + }, + "league/commonmark": { + "version": "2.10.3", + "hash": "tree-v2:ee619e9faf2cbab26a72cb89c809211c91e772fecf8c1a3cbc5e98f8a1233db2" + }, + "league/config": { + "version": "v1.2.0", + "hash": "tree-v2:b454eb2bd1b250346c7d0af0e5b3caaeafa1b21468bca4ff4601a6a7bd91baae" + }, + "league/flysystem": { + "version": "3.36.0", + "hash": "tree-v2:420e48fea76c143b41f4a8c1516031dc09ebb7e6367321af317e0a48fa998320" + }, + "league/flysystem-aws-s3-v3": { + "version": "3.35.3", + "hash": "tree-v2:b444006b5245a442e5623e546c1d49d3150330574d27da41ea2da01230df0938" + }, + "league/flysystem-local": { + "version": "3.35.3", + "hash": "tree-v2:7dba7c9670489313d3604b6a2801a152cf455b5e0d428bf721efda7ea37a5000" + }, + "league/flysystem-sftp-v3": { + "version": "3.36.0", + "hash": "tree-v2:a5da24792e8191c3e234b897466197a20b8f6d657ae1651b68592aaa743efdc4" + }, + "league/mime-type-detection": { + "version": "1.17.0", + "hash": "tree-v2:1726a64b700472e12c6e5917132914a632350a91b90816cc3c5940ddea1f92d7" + }, + "league/oauth1-client": { + "version": "v1.12.0", + "hash": "tree-v2:96e3c6b7cca4d1cc2b14391f0d38bd631afbd44aecd549be6426fe78214c01a2" + }, + "league/uri": { + "version": "7.8.1", + "hash": "tree-v2:4397d51a8c4e6080a42f161000bf9db6e73840654aff3cc445f27b405c36ab5c" + }, + "league/uri-interfaces": { + "version": "7.8.1", + "hash": "tree-v2:227c217ed67b9cd48cc589867bf18379e95858e068724b8fbef94713311b024e" + }, + "livewire/livewire": { + "version": "v3.8.10", + "hash": "tree-v2:53b5f60c4ed2d495c3a2fcb2b2390f87c7d26d5e54648b3f10efe4010d720924" + }, + "log1x/laravel-webfonts": { + "version": "v2.0.1", + "hash": "tree-v2:c192111c594d02411d0fbf9dab0837775b56a044c85dd30351d20a9779929872" + }, + "lorisleiva/laravel-actions": { + "version": "v2.12.0", + "hash": "tree-v2:88028833bc2531aa43a58f4af7f79fd6cc6c25d9f813732ff9b73e4df31d0a13" + }, + "lorisleiva/lody": { + "version": "v0.7.1", + "hash": "tree-v2:e02bb6ff9f6b29fb36c4e9a8daf8b3fe788ed55cdd2b616d88214976b18521c7" + }, + "monolog/monolog": { + "version": "3.12.1", + "hash": "tree-v2:bb7a293a3ba00d6ca00220a74a07a194fa22bdde721ae8533e8773c7c3589cc4" + }, + "mtdowling/jmespath.php": { + "version": "2.9.2", + "hash": "tree-v2:c87e5d6482253928932f38ada2184333cccc8be28081642f4f1b41dc66734822" + }, + "nesbot/carbon": { + "version": "3.14.1", + "hash": "tree-v2:a5a16a60481dc65e8852ac76a275ab81dcb42947cf82c48aac3b36f85a584a86" + }, + "nette/schema": { + "version": "v1.3.6", + "hash": "tree-v2:502542dfdcfa7c69b22954a883a7605ef7c0d399a15df5fa09aaf695d437b358" + }, + "nette/utils": { + "version": "v4.1.5", + "hash": "tree-v2:98a3f1d758153bf83c6da5b98625cb4b1ad1105369e07cb24ea29f60eff0ab63" + }, + "nikic/php-parser": { + "version": "v5.9.0", + "hash": "tree-v2:bcacf1331427ef1203828abd4716eae2d19aa68027126caa55860acf46057cf7" + }, + "nubs/random-name-generator": { + "version": "v2.2.0", + "hash": "tree-v2:a743094ff0ff78ddb63ec8664039da2cf81e089dc5f6c16b17520811e2e9cb84" + }, + "nunomaduro/termwind": { + "version": "v2.4.0", + "hash": "tree-v2:19bb7af2b1ec2c1a8187fca9af3615fe7ba53c12948053951f9175a1a03a0e48" + }, + "nyholm/psr7": { + "version": "1.8.2", + "hash": "tree-v2:13ffe5426fb64cceca6901fa1055a0688ec899fa490c81c1a63132b2d25d7ce4" + }, + "paragonie/constant_time_encoding": { + "version": "v3.1.3", + "hash": "tree-v2:49dd6b293347e87932021f7c295f914e69623eafd76080a2799b14139a1a51e8" + }, + "paragonie/random_compat": { + "version": "v9.99.100", + "hash": "tree-v2:3f247d4e9da196031d5ee2754558e7dc17e8d63d7506320fa384d0432c5450d1" + }, + "phpdocumentor/reflection-common": { + "version": "2.2.1", + "hash": "tree-v2:b5e96fe132bff07f74d641a9d92eed23f59de9bf11828c15afe4b0a79efede7f" + }, + "phpdocumentor/reflection-docblock": { + "version": "6.0.3", + "hash": "tree-v2:d4455a70c012b399cab192e5b46fc8609741b8c54d1d8d2ca241b51011f602e1" + }, + "phpdocumentor/type-resolver": { + "version": "2.1.0", + "hash": "tree-v2:b38f8e64f9e625074fc0b7f271a4f4d8100e7e0771c54ae2fae397fe68a45ea0" + }, + "phpoption/phpoption": { + "version": "1.10.0", + "hash": "tree-v2:88bf58174c3d5e79cc8ba99f6a072fc4c26e4aea72ad093b5d5a43d3d46ccea4" + }, + "phpseclib/phpseclib": { + "version": "3.0.57", + "hash": "tree-v2:55fd680d70046da18276ca8b09dd3f45507a955c8963f01b2b65e1d576241790" + }, + "phpstan/phpdoc-parser": { + "version": "2.3.6", + "hash": "tree-v2:871a2c3e3fce5505337233418c37cdac1515c9824110bf602cbb8216b4fab7cb" + }, + "pion/laravel-chunk-upload": { + "version": "v1.6.3", + "hash": "tree-v2:d443106f44dc4dc50a46446d4f18b2bbb95fc771e2e28f637e0a41ae53ac61cb" + }, + "poliander/cron": { + "version": "3.3.1", + "hash": "tree-v2:828c1437d249186e1dc7c8070b14b520c78460ede432120f647ce63187f89c02" + }, + "pragmarx/google2fa": { + "version": "v9.1.0", + "hash": "tree-v2:c4e6d87c5e09946a20bedb4a4c1b68f39385d1cef45b17bd9bec517e39479d2b" + }, + "psr/cache": { + "version": "3.0.0", + "hash": "tree-v2:ac46f271c3134fa0193f61ed54dab6b7cf822030f0b249f8c54f51a1803c867d" + }, + "psr/clock": { + "version": "1.0.0", + "hash": "tree-v2:2a7e0fd2ce370b76b21dafdf64121892c972ca91f4bed220b3505db4ec870674" + }, + "psr/container": { + "version": "2.0.2", + "hash": "tree-v2:e5aaa33e7ad4cc518518e652f7435918c83ef034fa359b53731e10501eb6d682" + }, + "psr/event-dispatcher": { + "version": "1.0.0", + "hash": "tree-v2:091c7ba644dd4b46f4ca5a1e99e696b646f8a2a87821a8ca36e3328aba6fa182" + }, + "psr/http-client": { + "version": "1.0.3", + "hash": "tree-v2:50063ed6dde84d09a63437f58a953b8aa0fdd3fcab44d5d8688a89fc48130f07" + }, + "psr/http-factory": { + "version": "1.1.0", + "hash": "tree-v2:dde23cb9ba5cc39c10acd8ade52be90b01927cc77ba64e3a7db3abe7c98f40ec" + }, + "psr/http-message": { + "version": "2.0", + "hash": "tree-v2:a9575ef4779a21eb0d3f04a1e2f0f008e67785cc86d750046a13455f9266ff41" + }, + "psr/log": { + "version": "3.0.2", + "hash": "tree-v2:0f88d9371ba05c1791394f2e0a56979e1e17258f9bea9519ffc51f11c85b7844" + }, + "psr/simple-cache": { + "version": "3.0.0", + "hash": "tree-v2:1854aa6d2cd53968d44c1fddcde547872925142ee6a5dea8d183b3a9e9551b7c" + }, + "psy/psysh": { + "version": "v0.12.24", + "hash": "tree-v2:c518e85e74d1aef81854220186172a87c7e1553652d35eff97655015389e5404" + }, + "purplepixie/phpdns": { + "version": "2.4.0", + "hash": "tree-v2:1c1ebac6d50b61cd01452f9c6cad774c2b641601925c67f33f6329b5d9cfa5c2" + }, + "pusher/pusher-php-server": { + "version": "7.3.0", + "hash": "tree-v2:c7713b62c33d0f6504f5226e33be144a1255125ec013abfe5ee06fa619c4a340" + }, + "radebatz/type-info-extras": { + "version": "1.0.9", + "hash": "tree-v2:8922f01f69a848e5332fe1e16033223e9afd552039df344b851fd2e2644488ca" + }, + "ralouphie/getallheaders": { + "version": "3.0.3", + "hash": "tree-v2:db666a4e690725b904b5de22a95b230e08ba09f45559a03b32ffb933b0abb75f" + }, + "ramsey/collection": { + "version": "2.1.1", + "hash": "tree-v2:ad16df2a5b3611348dc88ba0a921d27b7e074406ff667ca547c6a3579734972c" + }, + "ramsey/uuid": { + "version": "4.9.4", + "hash": "tree-v2:68d8f0c8847e8275812a7af6335ada47d71f8534bb96c5b54ea18141e4739d77" + }, + "ratchet/rfc6455": { + "version": "v0.4.1", + "hash": "tree-v2:f8ab0e11b00c48fdfa9849073c663a21708c640f4f6a054fdf330c0e66b7c32c" + }, + "react/cache": { + "version": "v1.2.0", + "hash": "tree-v2:303d5bc29045a7370e96e9760540265fe0df3639cfb03a0914a09e3b71d85cea" + }, + "react/dns": { + "version": "v1.14.0", + "hash": "tree-v2:59869424ef3faf16394bfd6957d3d42cdff87f7234ef1190b4028c56dfc8dc89" + }, + "react/event-loop": { + "version": "v1.6.0", + "hash": "tree-v2:0b4ee3a9e74158fc9a291bc3e44f74ded61bd2ecf5752f53b04936bb86890e96" + }, + "react/promise": { + "version": "v3.3.0", + "hash": "tree-v2:dea9b667d543e827b3640262c29c5c2348ee7535045fbb5ea9fd05b65526e8c4" + }, + "react/promise-timer": { + "version": "v1.11.0", + "hash": "tree-v2:40aacf5a4884e1c2a1c8f51e5db11daaea6486b8c92439eeaedaf8d54589b1bf" + }, + "react/socket": { + "version": "v1.17.0", + "hash": "tree-v2:4809bfe68cb0b7634391369c426e336355c696862bce27bf3314411293d7a493" + }, + "react/stream": { + "version": "v1.4.0", + "hash": "tree-v2:24cd9a269d6eef586865cd4525abfa3068f55e7c634a0d938071300df371fbb1" + }, + "resend/resend-laravel": { + "version": "v1.6.0", + "hash": "tree-v2:f5537049aaf9df6fac8e71c360f36367e7adabd64440681cc1005fcb5bc06c8a" + }, + "resend/resend-php": { + "version": "v1.16.0", + "hash": "tree-v2:41aee3a1e56f0c323e4c11b2204958d7e24716a4f6ba33a82a279fabdadbc698" + }, + "sentry/sentry": { + "version": "4.32.0", + "hash": "tree-v2:5a245d8c147faace301993c10f5f8df7e4b48d5c140fff3a1ca6dc91c763dffa" + }, + "sentry/sentry-laravel": { + "version": "4.28.0", + "hash": "tree-v2:01d427cba075614542d54618e2e2d226ca72033e124c2168f80264cbb03b7461" + }, + "socialiteproviders/authentik": { + "version": "5.3.0", + "hash": "tree-v2:b87546ceea927533581ec65ffa7f49bbc8eba19f53d3e86d276b0e266b447818" + }, + "socialiteproviders/clerk": { + "version": "5.1.0", + "hash": "tree-v2:dbbdb491ea001863c8aeab896e105552c4aef5635299cc59078bcd334f8c1264" + }, + "socialiteproviders/discord": { + "version": "4.2.0", + "hash": "tree-v2:b9643f1b280a1e4c705d6a0a60b450d241f83f6c07a958b79cc05ae5f2a8586e" + }, + "socialiteproviders/google": { + "version": "4.1.0", + "hash": "tree-v2:4f1286cbb3220ba557ed26e44f7341b6993cc9eb8afe3d1122be309f4a7200f4" + }, + "socialiteproviders/infomaniak": { + "version": "4.0.0", + "hash": "tree-v2:646fdffaaa711fb0698a2ec1647056f8c9cc23a9d4e170ea6ab62d44044993ca" + }, + "socialiteproviders/manager": { + "version": "4.10.0", + "hash": "tree-v2:af08ad057ab7afee0aa8cd58b5433227e49f5eb9dce326f663eb5915ece3e8e7" + }, + "socialiteproviders/microsoft-azure": { + "version": "5.2.1", + "hash": "tree-v2:cf389d095c3ed53db7e1b49192dff77af0fb63777d82bc87417f61cc27de3ed7" + }, + "socialiteproviders/zitadel": { + "version": "4.2.0", + "hash": "tree-v2:00f5f3c0138d49e42364152d590e5dc74a0284ce3bb55a954efd87c5bc72989e" + }, + "spatie/commonmark-shiki-highlighter": { + "version": "2.5.2", + "hash": "tree-v2:da985acdbdd912496897c3edd71e2f2547fd896059164dd5a36b628e3241fad5" + }, + "spatie/laravel-activitylog": { + "version": "4.12.3", + "hash": "tree-v2:471e238146b28116bfb231b2a2cacc1dc9a0228a08e0076b8a74da389cc175e9" + }, + "spatie/laravel-data": { + "version": "4.23.0", + "hash": "tree-v2:6391465cf6c5a21d901a30b4df29c3039e5e0accb6b41a0c3c71bb4f2b3deb67" + }, + "spatie/laravel-markdown": { + "version": "2.8.0", + "hash": "tree-v2:35ba0757f6a53e91cdbb12000f797cca394fb4d0694d84e274c65cc94ecc6170" + }, + "spatie/laravel-package-tools": { + "version": "1.93.3", + "hash": "tree-v2:4ae3f9910639e24ebdb2ee3220edcf93e27a380890e8c8442e361d113951e575" + }, + "spatie/laravel-schemaless-attributes": { + "version": "2.6.0", + "hash": "tree-v2:aceb5a7bfb0f178a24b4cb744bef3d9d467400b05687ed53c8bbb5aba7137168" + }, + "spatie/macroable": { + "version": "2.1.0", + "hash": "tree-v2:344a2e65018d7b2fe15a2e2a6fed2bebbcf859094233dca388c40df8261104d4" + }, + "spatie/php-structure-discoverer": { + "version": "2.4.4", + "hash": "tree-v2:1aa3de6b429a12a5a58af48f8e9721d349df02ace12561b8430ca769f866b468" + }, + "spatie/shiki-php": { + "version": "2.4.0", + "hash": "tree-v2:5f980c9f51efcf49a892464c70a40b2256cad3da2299ddb6f7f1ae6f51bb1bea" + }, + "spatie/url": { + "version": "2.4.0", + "hash": "tree-v2:ccfbd1e8a695b968ba0e8b70663c8313ee60235ec562c27e51d1e7ec20ca5881" + }, + "spomky-labs/cbor-php": { + "version": "3.4.2", + "hash": "tree-v2:5017024176dcdeeec0e9224abdc211bdad50da7d66d71fd4a7b5f4412b523290" + }, + "spomky-labs/pki-framework": { + "version": "1.6.3", + "hash": "tree-v2:8878022c5dc6d32ad0c56bb2806806d5ff1602ae9a10f5bbccd8027283ae8943" + }, + "stevebauman/purify": { + "version": "v6.3.2", + "hash": "tree-v2:c1a25d80b65af346fede09a960d4f643bc21cad4ec0bef81dc6741807d3d268c" + }, + "stripe/stripe-php": { + "version": "v16.6.0", + "hash": "tree-v2:aa959d41f9c5b2142865352c3180308abb91a79c051c11b2a63f05d5255aa34d" + }, + "symfony/clock": { + "version": "v8.1.0", + "hash": "tree-v2:7888f31d60c29c4f777204510e9a60a557c98a4e480efe5d9eb13dd617bf21f1" + }, + "symfony/console": { + "version": "v7.4.20", + "hash": "tree-v2:1473afc4cb70ccac83b3bb8b6683438c5901a5d3fcd8c3b2c2c84da3a2f5b499" + }, + "symfony/css-selector": { + "version": "v8.1.6", + "hash": "tree-v2:d8c1eda011461f48443b0fcb1d607979dea4d8f0c1e9e85cee5e9d2f27613cc8" + }, + "symfony/deprecation-contracts": { + "version": "v3.7.1", + "hash": "tree-v2:5833e98c5092cd02ed429ffd459b82b158f20393ef0b0ec2e06b2833d7efe838" + }, + "symfony/error-handler": { + "version": "v7.4.20", + "hash": "tree-v2:cee97a6ff75008c349ab2b93bf74a109bde62c4d769e6b616bf2c2235956807d" + }, + "symfony/event-dispatcher": { + "version": "v8.1.5", + "hash": "tree-v2:44ed3dd86aee506338f18bd5293ebfd1e23ead8659903a8bc020cd1a263dba0a" + }, + "symfony/event-dispatcher-contracts": { + "version": "v3.7.1", + "hash": "tree-v2:0ac7e6b0bd6da93975ac9b3ec9f41aa596e9c1c6217813797d1e8bb64be89139" + }, + "symfony/filesystem": { + "version": "v8.1.6", + "hash": "tree-v2:0988ddfa5dbfd1d49f57b456feac3132e9ad72c09a8e994b3edcdbb3d8a48a4f" + }, + "symfony/finder": { + "version": "v7.4.20", + "hash": "tree-v2:c0cb8d8344fdae5029d26bdf249f140beb07180652173fd91b992585c8380753" + }, + "symfony/http-foundation": { + "version": "v7.4.20", + "hash": "tree-v2:6f0ed46431710070db850c88d9218344a14db9b33262bab8cd7a89c1b68696d6" + }, + "symfony/http-kernel": { + "version": "v7.4.20", + "hash": "tree-v2:c304a88316c6d8ec8255aa9471c47973bcac5b28b042521e1bf2aec5ae0bb670" + }, + "symfony/mailer": { + "version": "v7.4.19", + "hash": "tree-v2:a5b4eeb63c3601d56b876f7c331a2f5c8282fe5ad0cfcaca72af7db25ad823e5" + }, + "symfony/mime": { + "version": "v7.4.19", + "hash": "tree-v2:1e8a3ebeaabd6609d46b43ca7d92e2229ad4d7733292181792788556be171a54" + }, + "symfony/options-resolver": { + "version": "v8.1.0", + "hash": "tree-v2:460463358ebcddeefa642be1bbdef7cae975b10da77c231d109fb4d720275f44" + }, + "symfony/polyfill-ctype": { + "version": "v1.37.0", + "hash": "tree-v2:8a05c9979125b75411bed1356ebf16439972fafc4cca1ff6baa717db25e3dfca" + }, + "symfony/polyfill-intl-grapheme": { + "version": "v1.43.0", + "hash": "tree-v2:5e6dbd2dc74543299269b89eb6e2ba8c170b51b1697674fbfe681c256e55dd47" + }, + "symfony/polyfill-intl-idn": { + "version": "v1.43.0", + "hash": "tree-v2:c923d31d3d92c64e387245034bf7ca44284e3ec6674004efd41e7d13da99a492" + }, + "symfony/polyfill-intl-normalizer": { + "version": "v1.43.0", + "hash": "tree-v2:cd506f4802cec64c0a39b6fa45cf9e22bffc84e674442ae93b6eaf936304ec5d" + }, + "symfony/polyfill-mbstring": { + "version": "v1.43.0", + "hash": "tree-v2:1178bc6cf2eca3121b218c648dc6b60c5596ba469520be8aa5744cf105dd9e5b" + }, + "symfony/polyfill-php80": { + "version": "v1.43.0", + "hash": "tree-v2:dac9a7018f6de962c077a8a96a80deaf4da282d85250f36c90e293fde4804664" + }, + "symfony/polyfill-php81": { + "version": "v1.38.1", + "hash": "tree-v2:fa626280ca82a3b621da208fe448441ec61a315e5f968fabe708d8ab0ebf83f3" + }, + "symfony/polyfill-php83": { + "version": "v1.43.0", + "hash": "tree-v2:d6cc98eac4a84a4ad1bffbf3b5ae52ce3716d8e8f8ef74b2a76172ce07545d9e" + }, + "symfony/polyfill-php84": { + "version": "v1.43.0", + "hash": "tree-v2:0e8b2fc36bad3b28730f58da447ce185e1bc0b65e905d8a08f409b8444f853d4" + }, + "symfony/polyfill-php85": { + "version": "v1.43.0", + "hash": "tree-v2:7399609adce52ef72aab96a3db35805814b486e6a46d30b2ed8e69f587eb3c8f" + }, + "symfony/polyfill-uuid": { + "version": "v1.43.0", + "hash": "tree-v2:d5a39f5ddaf6c4b4b53829f8cca739b82ac309b5066ac170665b010e03fe6fc1" + }, + "symfony/process": { + "version": "v7.4.19", + "hash": "tree-v2:2fa5a43314ce3843a467b733c1db2c64b861424a177e07c7ded950ac8712edb7" + }, + "symfony/property-access": { + "version": "v8.1.4", + "hash": "tree-v2:5f465a4a0eb7e2e8fbe080af2bf95e4c4d3fd4fe272bdf98efc63ed5c0794073" + }, + "symfony/property-info": { + "version": "v8.1.8", + "hash": "tree-v2:08cd925cac3b082917a9c19315571388a1c66fba31fe438dae4af59238f45f55" + }, + "symfony/psr-http-message-bridge": { + "version": "v8.1.0", + "hash": "tree-v2:5436ca31402054df5b2f0112afa655297032380d0db45baa946bad4253a8a324" + }, + "symfony/routing": { + "version": "v7.4.20", + "hash": "tree-v2:19428320d0ea2565dc895a1d706d8c94497ccca8ffbff34422369fd3d9a828c2" + }, + "symfony/serializer": { + "version": "v8.1.8", + "hash": "tree-v2:af5d415bda361730ecfbb542dc810a7d98ac00922c9fc625e740d3e221d645ef" + }, + "symfony/service-contracts": { + "version": "v3.7.3", + "hash": "tree-v2:87421779a4d0f4aae910ba4a77c7a04bfd6d385d86190a96ec8276ec1ba48987" + }, + "symfony/string": { + "version": "v8.1.7", + "hash": "tree-v2:e51a49ae27601177aa109b457a6fa3c578e8df63f80845a3101095010791f66f" + }, + "symfony/translation": { + "version": "v8.1.5", + "hash": "tree-v2:5ed59b86402810a7cc27e7abb649b87fe5e32e20f982a025dee49333b77c0182" + }, + "symfony/translation-contracts": { + "version": "v3.7.1", + "hash": "tree-v2:74a8e3a8f0580af687b411de0e087911c348807f6e59858632fbffabeb7bf9a9" + }, + "symfony/type-info": { + "version": "v8.1.8", + "hash": "tree-v2:f5b0432361367fb8453952c9821a05fb2aadf6918fed80cb82a8b9bea4cc039c" + }, + "symfony/uid": { + "version": "v7.4.20", + "hash": "tree-v2:9c354ab4077f1df6443bda228b659e01c97a28bbc64ad36b15f9976cb66bdbce" + }, + "symfony/var-dumper": { + "version": "v7.4.18", + "hash": "tree-v2:6cf1763f4c700d9ead924182f1176ee815be6414cb1ea67bc6e92d13fc3461b2" + }, + "symfony/yaml": { + "version": "v8.1.8", + "hash": "tree-v2:8d990fa9be574dec0fbb19199b855c71c40515b9feebaa996d44de7c954143ee" + }, + "tijsverkoyen/css-to-inline-styles": { + "version": "v2.4.0", + "hash": "tree-v2:ac5b86c6dee0dc8f5c5f7f98d250d378d8c65dde04156022650e4776734f7fcb" + }, + "visus/cuid2": { + "version": "7.0.0", + "hash": "tree-v2:9558deceb662c50e421e5e42402f973d6cdf72af45322f5274337f6fb741ad2a" + }, + "vlucas/phpdotenv": { + "version": "v5.7.0", + "hash": "tree-v2:6c06f3ef79457956cc5468b35ba53b18504b47a0925d29278c2b195caa85bae0" + }, + "voku/portable-ascii": { + "version": "2.1.1", + "hash": "tree-v2:6c1b22272d89eb3d85366cc99a0375535b76a897616d9d79bbfddc8bcded66c6" + }, + "web-auth/cose-lib": { + "version": "4.8.2", + "hash": "tree-v2:de961d934f2e526f30b69334bb474ac1aa3ff77389998ea9d0f6c02ffe493936" + }, + "web-auth/webauthn-lib": { + "version": "5.3.9", + "hash": "tree-v2:e0949da9b86423ae77ef6a89f893c59bf769748d6a73ef90b6b0c35ea62e8209" + }, + "webmozart/assert": { + "version": "2.4.1", + "hash": "tree-v2:7376c95163e3fc61ad793a2342e94ff787b01739b28ecce59d5acd1ce8cc691b" + }, + "yosymfony/parser-utils": { + "version": "v2.0.0", + "hash": "tree-v2:96588dacd406278c80997538f85fade4a9d6819d5a5a295253a15bad7bf3f7d7" + }, + "yosymfony/toml": { + "version": "v1.0.4", + "hash": "tree-v2:06d6d20af34021a04bb906b2f8b28a82dc986db7e17234bbae7074b1235e7119" + }, + "zircote/swagger-php": { + "version": "6.11.0", + "hash": "tree-v2:d6535e60c5f64c53eba5cfe4f8729ae437b0007f2a0d67d2a2daeae3bf01f79f" + } + }, + "require-dev": { + "amphp/amp": { + "version": "v3.1.3", + "hash": "tree-v2:d3497306739d19b5afaaba19043930ea8eef2470ccd5592f03129fa374f172d7" + }, + "amphp/byte-stream": { + "version": "v2.1.2", + "hash": "tree-v2:76f032d87c398e38a37a7aa8ae50496885edf16cee70178be260eefc17ada6d2" + }, + "amphp/cache": { + "version": "v2.0.1", + "hash": "tree-v2:d7d30faedc5d9aaadb6cd48d296f2bcc202f5b9e9251cb749c07196398da42de" + }, + "amphp/dns": { + "version": "v2.4.1", + "hash": "tree-v2:5244fcc714f0651538775d9749fb14f2d7980ec3b3f472c85724f6dd7a37efc1" + }, + "amphp/hpack": { + "version": "v3.2.2", + "hash": "tree-v2:224439b904b3e3837e611183925e51859b7cbb85f80143871def0d16484578b7" + }, + "amphp/http": { + "version": "v2.1.2", + "hash": "tree-v2:0c7b6dcb02552516528fc98a6ba8e92908caa010b5eb8e4471f1f12f1c9f3d0e" + }, + "amphp/http-client": { + "version": "v5.3.6", + "hash": "tree-v2:5d4dd74df9cdc7b087b295b67a7c00172d8c9d2099ea5599613c9f5579c52819" + }, + "amphp/http-server": { + "version": "v3.4.6", + "hash": "tree-v2:9a3424c8f2833ed3f24488f9252991502e945c79c0b0e8485ca48b4132387538" + }, + "amphp/parser": { + "version": "v1.1.1", + "hash": "tree-v2:323c0ca63bcacc384f97693e57a1b4d4d25e31075633c87dbf9bb52aefdaf074" + }, + "amphp/pipeline": { + "version": "v1.2.7", + "hash": "tree-v2:91f9d66bbf42eb4dbf2984ea7d8ee6fe9931db442487fde46313a3f840334957" + }, + "amphp/process": { + "version": "v2.1.0", + "hash": "tree-v2:9f2b957f18f84012aa2db032780bca4bdc0468c6b55fe8112fa609430c7bc021" + }, + "amphp/serialization": { + "version": "v1.1.0", + "hash": "tree-v2:a4cd50472d471a9d10fcafdb712cb3efc112bb2e643814529bb6195d43acdce4" + }, + "amphp/socket": { + "version": "v2.4.1", + "hash": "tree-v2:cfe7178350f2a9cacd4b6ea1e5f68ba14889162e04838b04b279cb8389dd780b" + }, + "amphp/sync": { + "version": "v2.3.0", + "hash": "tree-v2:481be54c4d2517271fa742d678abfa01fc7d6d9e4ce1c5df59ccc184b51a4fa1" + }, + "amphp/websocket": { + "version": "v2.0.4", + "hash": "tree-v2:2cccf30e15de80faebf3e958db38aaabea0619a65e2162c6d26b0970d0bb17db" + }, + "amphp/websocket-client": { + "version": "v2.0.2", + "hash": "tree-v2:8ec4699d0c0c88cb401db507d2bc24438050f961bb7efceea2cae31b36c53483" + }, + "brianium/paratest": { + "version": "v7.20.0", + "hash": "tree-v2:075f8b7e73532ba3689126db0f91288a199bcba5f2743bc17a9bf37d53e030c1" + }, + "composer/pcre": { + "version": "3.4.0", + "hash": "tree-v2:ed02f4e2009261111a40bc3f96d602af9d3d2b069df5578bcd5ca8641e55d583" + }, + "composer/semver": { + "version": "3.5.0", + "hash": "tree-v2:248e81e763c49385d13df21a723d156777290b327a2cd119201d4052e26fcb38" + }, + "composer/xdebug-handler": { + "version": "3.0.5", + "hash": "tree-v2:780bdd65290fbfc62bbe8de682feb4eff689bf57ac9598bd42dc83f60e734adf" + }, + "daverandom/libdns": { + "version": "v2.1.0", + "hash": "tree-v2:a4f2d4abdf003756bf9b88bb6c55c8a70d8d214c81529273974c9ddda020fe02" + }, + "driftingly/rector-laravel": { + "version": "2.6.2", + "hash": "tree-v2:7ad85f8d2c4fc8ae611466f654d894413f9d066999bbede4abdccda6f230be4e" + }, + "fakerphp/faker": { + "version": "v1.24.1", + "hash": "tree-v2:b6f3d0f8ca79f98400b0df7c2a29004dbd9e3f39f39125ecfd5613e38b9ff6a3" + }, + "fidry/cpu-core-counter": { + "version": "1.4.1", + "hash": "tree-v2:187d5e8e97709597df098f058a60567f02d205ecf769583864ae461552cdae95" + }, + "filp/whoops": { + "version": "2.18.5", + "hash": "tree-v2:8c32519eacbd19b8d00468e9a8aaf35efca825f90c912fd802d74494d97e1ca5" + }, + "hamcrest/hamcrest-php": { + "version": "v3.0.0", + "hash": "tree-v2:49f1c99167659adb6f9f9bf1aa7abca02fcd773f3c19041f7983dd50b83d85e7" + }, + "kelunik/certificate": { + "version": "v1.1.3", + "hash": "tree-v2:760bb76fec70d2a8530c4968b49720c08824cd8eebb27dee38370d8e71ee5855" + }, + "laravel/boost": { + "version": "v2.10.0", + "hash": "tree-v2:c88d4a35bf2e78d321dca9e8c342c6de41d85d06e840d0f1149d5990ac7c3a3a" + }, + "laravel/pint": { + "version": "v1.32.1", + "hash": "tree-v2:6011ed0cc05d840c85dac563c64a2461bc3c09f131e7c819f75abd73c9502f64" + }, + "laravel/roster": { + "version": "v1.0.0", + "hash": "tree-v2:da430a9f3d9fb06a3812b0e8a116d8265a06dce1bfde4182a1de7154ba15ad67" + }, + "laravel/vet": { + "version": "v0.2.1", + "hash": "tree-v2:7c555b9f7eb265649d89769aa9b07bafc809d9e52d15ecdc3a84ee1c59fa767e" + }, + "league/uri-components": { + "version": "7.8.1", + "hash": "tree-v2:a2b928f6fae0e15c66f08ae84e98f7dd8547749390ca842c83014bf1f2d68777" + }, + "mockery/mockery": { + "version": "1.6.15", + "hash": "tree-v2:4bc0e2fbaea02a9469caa0f1105242dca2907278bc1fd8a3f9c6c8a2ddf920d7" + }, + "myclabs/deep-copy": { + "version": "1.14.0", + "hash": "tree-v2:6f013d251808abbe250faa2a847f14d89804ba137fc7e085495311bc6ebc1822" + }, + "nunomaduro/collision": { + "version": "v8.9.5", + "hash": "tree-v2:937038b0d056a8bcf7697a75ce737986eccb501a325645cf05d679e7befcf39e" + }, + "pestphp/pest": { + "version": "v4.7.8", + "hash": "tree-v2:36d984a67927f4dc359da32a74528232c736b60015a6e8eb194c50076fee6544" + }, + "pestphp/pest-plugin": { + "version": "v4.0.0", + "hash": "tree-v2:3103a3f5c29b851523801f4168e91bf6d802ef5e65706fd83273c24c4ec95549" + }, + "pestphp/pest-plugin-arch": { + "version": "v4.0.2", + "hash": "tree-v2:37a528a6ea7283adfb14f06e80ce4dd3884a2917cc372b204c221f206b3bfe98" + }, + "pestphp/pest-plugin-browser": { + "version": "v4.3.1", + "hash": "tree-v2:a9294251c3d572bcfa8ef508c2e1c88bc100fb248b01a4cd0be088b7677d0494" + }, + "pestphp/pest-plugin-mutate": { + "version": "v4.0.1", + "hash": "tree-v2:90dc0a102fcecaf0314015c17c0e98e3f2e5151247da5809a174a731e8b2db89" + }, + "pestphp/pest-plugin-profanity": { + "version": "v4.2.1", + "hash": "tree-v2:88b1581205235488496300f5ee233b42b122f5a5668bb2d95f8169c180121cd9" + }, + "phar-io/manifest": { + "version": "2.0.4", + "hash": "tree-v2:7b98d0ed9886b77df04a08280884b27c7169ba845a24c626ca7abea0d3afc1f7" + }, + "phar-io/version": { + "version": "3.2.1", + "hash": "tree-v2:0409e2019377650177c2b10da803617abc85e49d5b02b8697d450c007eab42b4" + }, + "phpstan/phpstan": { + "version": "2.2.16", + "hash": "tree-v2:2c6a2e25bc83ef5a18b9a3a5fb9ac9464160a2e414cf4df481d3810becefdde1" + }, + "phpunit/php-code-coverage": { + "version": "12.5.7", + "hash": "tree-v2:5cf24f83c21b6d8fce23e7ad853f77f0807e76f4c84d4f7ffb9053a167df16ca" + }, + "phpunit/php-file-iterator": { + "version": "6.0.2", + "hash": "tree-v2:5899b835455b09f32cda45548e9aaa437dd3aa9508c5960076d081175aa53293" + }, + "phpunit/php-invoker": { + "version": "6.0.0", + "hash": "tree-v2:6d8f648edbdac676ca9bee9c2fe97c82d6251aaf62f156c3a36c86736b812e23" + }, + "phpunit/php-text-template": { + "version": "5.0.0", + "hash": "tree-v2:4470e41670dd466f1af97fb08a0705d5e31c1d09f6f09ece2404cbd7ae6a709b" + }, + "phpunit/php-timer": { + "version": "8.0.0", + "hash": "tree-v2:f35fccbffc23c168f18029a433791f4dd85daa7009e53987b66f08bdbf3d4353" + }, + "phpunit/phpunit": { + "version": "12.5.33", + "hash": "tree-v2:fe7d4ec3e4840290cee07c39535422b61bb31ebd2afde15a39a78373a0cd2c64" + }, + "rector/rector": { + "version": "2.6.7", + "hash": "tree-v2:9e83455ef9d1fa0a25829c840dd17ad00620e4b315bed4aee4fde6a2aa10cac5" + }, + "revolt/event-loop": { + "version": "v1.0.9", + "hash": "tree-v2:0ce99bc9177402510544459689f3c8f2abae7466e27abf1eefb7da8888bc0198" + }, + "sebastian/cli-parser": { + "version": "4.2.1", + "hash": "tree-v2:fc44fc3e0a4b1fd529963948acebc3a3370f7b55aa8e9969c96953477b41886e" + }, + "sebastian/comparator": { + "version": "7.1.8", + "hash": "tree-v2:10f9243c8a5287b8ad9e7ed4651717391adaf923891a95d75b589fa2229f5386" + }, + "sebastian/complexity": { + "version": "5.0.0", + "hash": "tree-v2:f0d6eb200ba389679d875eb854ffc9d6c55884d1e5b5bdcd2e58cb27d505c362" + }, + "sebastian/diff": { + "version": "7.0.1", + "hash": "tree-v2:97deb1c25a59326f762c614ccc25be2163296ae70762785f12d307ed967a5326" + }, + "sebastian/environment": { + "version": "8.1.2", + "hash": "tree-v2:f6e34bc8513b7d4b3c319a99bd457c406b3ef4968db30b33c07346a6c0d46c06" + }, + "sebastian/exporter": { + "version": "7.0.3", + "hash": "tree-v2:14c6a001c221469a25b6bb02ab8b7684b7e64ed98379ec6e7673853d7ee3c0a4" + }, + "sebastian/global-state": { + "version": "8.0.3", + "hash": "tree-v2:61b9dda526628a2c6b44092338d4363c3b682f4290e71b9bffed49e70327f10c" + }, + "sebastian/lines-of-code": { + "version": "4.0.1", + "hash": "tree-v2:10204087c002cc8387fd29d84327bec5b68cd528a854234fb79f0e29b6f12cde" + }, + "sebastian/object-enumerator": { + "version": "7.0.0", + "hash": "tree-v2:7e0147f70c38947d32d81b0a88607da16c0b067ba9c357febe9779639e55d69f" + }, + "sebastian/object-reflector": { + "version": "5.0.0", + "hash": "tree-v2:dc02c4b2cc26efffc26b74e5bc0e489a4ac3ce831fff0f06c939fef5b8285fc3" + }, + "sebastian/recursion-context": { + "version": "7.0.1", + "hash": "tree-v2:c2713c083e6e14599b6a68f986d969692458ff45a1aedf5a6bd994699c6cc72d" + }, + "sebastian/type": { + "version": "6.0.4", + "hash": "tree-v2:c18e590b1fad7140ccc8dc16086635356728f15f0eb1abfb4d3d79b243947a35" + }, + "sebastian/version": { + "version": "6.0.0", + "hash": "tree-v2:b900b2263d6a49e35e6c9adf9d477f927d769f6a019ae4532a6867b44506f456" + }, + "serversideup/spin": { + "version": "v3.5.2", + "hash": "tree-v2:21e14ccdd73e58677be9802890fd8d73dc3bd608e7693451ed63c34329cfe2a0" + }, + "staabm/side-effects-detector": { + "version": "1.0.5", + "hash": "tree-v2:26d964435c381c94abd5dbadf497d7888b5a811f0693b66aa7976a3d01c545ba" + }, + "symfony/http-client": { + "version": "v8.1.8", + "hash": "tree-v2:4ef9be0a8af777834c59a7d18c442cbb950b4e5e4d725c22250d301d1ffb7ccb" + }, + "symfony/http-client-contracts": { + "version": "v3.7.3", + "hash": "tree-v2:3fba0792bd4d55c49f97b475328f8f87d3c0f761c4a533d03b7f9afea731046f" + }, + "symplify/rule-doc-generator-contracts": { + "version": "11.2.0", + "hash": "tree-v2:143fc89ce64308c660e6f52de2df92429720106e1e43e1484dd93193772ed011" + }, + "ta-tikoma/phpunit-architecture-test": { + "version": "0.8.7", + "hash": "tree-v2:068328d7bed81666d79d94c08e5b634b6c2f1499e8ebadf1068b4d7f19545243" + }, + "theseer/tokenizer": { + "version": "2.0.1", + "hash": "tree-v2:41aaedd9e64befbc0fecc9ff7ea3ea811daa1088ea055614faa0aeef41f8744f" + } + }, + "minimum-release-age": 3 +}