diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index ea3998f8a..51ea8fcbf 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -192,6 +192,7 @@ public function applications(Request $request) 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], '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', 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], ], ) ), @@ -342,6 +343,7 @@ public function create_public_application(Request $request) 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], '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', 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], ], ) ), @@ -492,6 +494,7 @@ public function create_private_gh_app_application(Request $request) 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], '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', 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], ], ) ), @@ -626,6 +629,7 @@ public function create_private_deploy_key_application(Request $request) 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], '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', 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], ], ) ), @@ -757,6 +761,7 @@ public function create_dockerfile_application(Request $request) 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], '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', 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], ], ) ), @@ -927,7 +932,7 @@ private function create_application(Request $request, $type) if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', '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', 'base_directory', 'publish_directory', 'health_check_enabled', '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', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', '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']; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', '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', 'base_directory', 'publish_directory', 'health_check_enabled', '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', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', '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']; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -940,6 +945,7 @@ private function create_application(Request $request, $type) 'is_http_basic_auth_enabled' => 'boolean', 'http_basic_auth_username' => 'string|nullable', 'http_basic_auth_password' => 'string|nullable', + 'autogenerate_domain' => 'boolean', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -964,6 +970,7 @@ private function create_application(Request $request, $type) } $serverUuid = $request->server_uuid; $fqdn = $request->domains; + $autogenerateDomain = $request->boolean('autogenerate_domain', true); $instantDeploy = $request->instant_deploy; $githubAppUuid = $request->github_app_uuid; $useBuildServer = $request->use_build_server; @@ -1087,6 +1094,11 @@ private function create_application(Request $request, $type) $application->settings->save(); } $application->refresh(); + // Auto-generate domain if requested and no custom domain provided + if ($autogenerateDomain && blank($fqdn)) { + $application->fqdn = generateUrl(server: $server, random: $application->uuid); + $application->save(); + } if ($application->settings->is_container_label_readonly_enabled) { $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); @@ -1238,6 +1250,11 @@ private function create_application(Request $request, $type) $application->save(); $application->refresh(); + // Auto-generate domain if requested and no custom domain provided + if ($autogenerateDomain && blank($fqdn)) { + $application->fqdn = generateUrl(server: $server, random: $application->uuid); + $application->save(); + } if (isset($useBuildServer)) { $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); @@ -1367,6 +1384,11 @@ private function create_application(Request $request, $type) $application->environment_id = $environment->id; $application->save(); $application->refresh(); + // Auto-generate domain if requested and no custom domain provided + if ($autogenerateDomain && blank($fqdn)) { + $application->fqdn = generateUrl(server: $server, random: $application->uuid); + $application->save(); + } if (isset($useBuildServer)) { $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); @@ -1461,6 +1483,11 @@ private function create_application(Request $request, $type) $application->git_branch = 'main'; $application->save(); $application->refresh(); + // Auto-generate domain if requested and no custom domain provided + if ($autogenerateDomain && blank($fqdn)) { + $application->fqdn = generateUrl(server: $server, random: $application->uuid); + $application->save(); + } if (isset($useBuildServer)) { $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); @@ -1554,6 +1581,11 @@ private function create_application(Request $request, $type) $application->git_branch = 'main'; $application->save(); $application->refresh(); + // Auto-generate domain if requested and no custom domain provided + if ($autogenerateDomain && blank($fqdn)) { + $application->fqdn = generateUrl(server: $server, random: $application->uuid); + $application->save(); + } if (isset($useBuildServer)) { $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 488653fb1..84bde5393 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -178,4 +178,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('use_build_server'); $request->offsetUnset('is_static'); $request->offsetUnset('force_domain_override'); + $request->offsetUnset('autogenerate_domain'); } diff --git a/tests/Unit/Api/ApplicationAutogenerateDomainTest.php b/tests/Unit/Api/ApplicationAutogenerateDomainTest.php new file mode 100644 index 000000000..766033618 --- /dev/null +++ b/tests/Unit/Api/ApplicationAutogenerateDomainTest.php @@ -0,0 +1,131 @@ +andReturn(null); + Illuminate\Support\Facades\Log::shouldReceive('info')->andReturn(null); +}); + +it('generateUrl produces correct URL with wildcard domain', function () { + $serverSettings = Mockery::mock(ServerSetting::class); + $serverSettings->wildcard_domain = 'http://example.com'; + + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('getAttribute') + ->with('settings') + ->andReturn($serverSettings); + + // Mock data_get to return the wildcard domain + $wildcard = data_get($server, 'settings.wildcard_domain'); + + expect($wildcard)->toBe('http://example.com'); + + // Test the URL generation logic manually (simulating generateUrl behavior) + $random = 'abc123-def456'; + $url = Spatie\Url\Url::fromString($wildcard); + $host = $url->getHost(); + $scheme = $url->getScheme(); + + $generatedUrl = "$scheme://{$random}.$host"; + + expect($generatedUrl)->toBe('http://abc123-def456.example.com'); +}); + +it('generateUrl falls back to sslip when no wildcard domain', function () { + // Test the sslip fallback logic for IPv4 + $ip = '192.168.1.100'; + $fallbackDomain = "http://{$ip}.sslip.io"; + + $random = 'test-uuid'; + $url = Spatie\Url\Url::fromString($fallbackDomain); + $host = $url->getHost(); + $scheme = $url->getScheme(); + + $generatedUrl = "$scheme://{$random}.$host"; + + expect($generatedUrl)->toBe('http://test-uuid.192.168.1.100.sslip.io'); +}); + +it('autogenerate_domain defaults to true', function () { + // Create a mock request + $request = new Illuminate\Http\Request; + + // When autogenerate_domain is not set, boolean() should return the default (true) + $autogenerateDomain = $request->boolean('autogenerate_domain', true); + + expect($autogenerateDomain)->toBeTrue(); +}); + +it('autogenerate_domain can be set to false', function () { + // Create a request with autogenerate_domain set to false + $request = new Illuminate\Http\Request(['autogenerate_domain' => false]); + + $autogenerateDomain = $request->boolean('autogenerate_domain', true); + + expect($autogenerateDomain)->toBeFalse(); +}); + +it('autogenerate_domain can be set to true explicitly', function () { + // Create a request with autogenerate_domain set to true + $request = new Illuminate\Http\Request(['autogenerate_domain' => true]); + + $autogenerateDomain = $request->boolean('autogenerate_domain', true); + + expect($autogenerateDomain)->toBeTrue(); +}); + +it('domain is not auto-generated when domains field is provided', function () { + // Test the logic: if domains is set, autogenerate should be skipped + $fqdn = 'https://myapp.example.com'; + $autogenerateDomain = true; + + // The condition: $autogenerateDomain && blank($fqdn) + $shouldAutogenerate = $autogenerateDomain && blank($fqdn); + + expect($shouldAutogenerate)->toBeFalse(); +}); + +it('domain is auto-generated when domains field is empty and autogenerate is true', function () { + // Test the logic: if domains is empty and autogenerate is true, should generate + $fqdn = null; + $autogenerateDomain = true; + + // The condition: $autogenerateDomain && blank($fqdn) + $shouldAutogenerate = $autogenerateDomain && blank($fqdn); + + expect($shouldAutogenerate)->toBeTrue(); + + // Also test with empty string + $fqdn = ''; + $shouldAutogenerate = $autogenerateDomain && blank($fqdn); + + expect($shouldAutogenerate)->toBeTrue(); +}); + +it('domain is not auto-generated when autogenerate is false', function () { + // Test the logic: if autogenerate is false, should not generate even if domains is empty + $fqdn = null; + $autogenerateDomain = false; + + // The condition: $autogenerateDomain && blank($fqdn) + $shouldAutogenerate = $autogenerateDomain && blank($fqdn); + + expect($shouldAutogenerate)->toBeFalse(); +}); + +it('removeUnnecessaryFieldsFromRequest removes autogenerate_domain', function () { + $request = new Illuminate\Http\Request([ + 'autogenerate_domain' => true, + 'name' => 'test-app', + 'project_uuid' => 'abc123', + ]); + + // Simulate removeUnnecessaryFieldsFromRequest + $request->offsetUnset('autogenerate_domain'); + + expect($request->has('autogenerate_domain'))->toBeFalse(); + expect($request->has('name'))->toBeTrue(); +});