From 0706e5b502b853a142710d55ecc2b03858bf605e Mon Sep 17 00:00:00 2001 From: Alberto Rizzi Date: Wed, 7 Jan 2026 16:33:52 +0100 Subject: [PATCH 1/4] feat(ui): improve global search with UUID and PR support - Add UUID to search terms for applications, services, and databases - Add PR search terms for applications with preview enabled - Include service component names (applications and databases) in search - Add eager loading for previews and databases relationships --- app/Livewire/GlobalSearch.php | 38 ++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index e4108668b..ed9115093 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -257,7 +257,7 @@ class GlobalSearch extends Component // Get all applications $applications = Application::ownedByCurrentTeam() - ->with(['environment.project']) + ->with(['environment.project', 'previews:id,application_id,pull_request_id']) ->get() ->map(function ($app) { // Collect all FQDNs from the application @@ -286,6 +286,16 @@ class GlobalSearch extends Component $fqdnsString = $fqdns->implode(' '); + // Add PR search terms if preview is enabled + $prSearchTerms = ''; + if ($app->preview_enabled ?? false) { + $prIds = collect($app->previews ?? []) + ->pluck('pull_request_id') + ->map(fn ($id) => "pr-{$id} pr{$id} {$id}") + ->implode(' '); + $prSearchTerms = $prIds; + } + return [ 'id' => $app->id, 'name' => $app->name, @@ -296,13 +306,13 @@ class GlobalSearch extends Component 'project' => $app->environment->project->name ?? null, 'environment' => $app->environment->name ?? null, 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI - 'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString.' application applications app apps'), + 'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString.' '.$app->uuid.' '.$prSearchTerms.' application applications app apps'), ]; }); // Get all services $services = Service::ownedByCurrentTeam() - ->with(['environment.project', 'applications']) + ->with(['environment.project', 'applications', 'databases']) ->get() ->map(function ($service) { // Collect all FQDNs from service applications @@ -315,6 +325,10 @@ class GlobalSearch extends Component } $fqdnsString = $fqdns->implode(' '); + // Collect service component names for container search + $serviceAppNames = collect($service->applications ?? [])->pluck('name')->implode(' '); + $serviceDbNames = collect($service->databases ?? [])->pluck('name')->implode(' '); + return [ 'id' => $service->id, 'name' => $service->name, @@ -325,7 +339,7 @@ class GlobalSearch extends Component 'project' => $service->environment->project->name ?? null, 'environment' => $service->environment->name ?? null, 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI - 'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString.' service services'), + 'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString.' '.$service->uuid.' '.$serviceAppNames.' '.$serviceDbNames.' service services'), ]; }); @@ -348,7 +362,7 @@ class GlobalSearch extends Component 'link' => $db->link(), 'project' => $db->environment->project->name ?? null, 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' postgresql '.$db->description.' database databases db'), + 'search_text' => strtolower($db->name.' '.$db->uuid.' postgresql '.$db->description.' database databases db'), ]; }) ); @@ -369,7 +383,7 @@ class GlobalSearch extends Component 'link' => $db->link(), 'project' => $db->environment->project->name ?? null, 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' mysql '.$db->description.' database databases db'), + 'search_text' => strtolower($db->name.' '.$db->uuid.' mysql '.$db->description.' database databases db'), ]; }) ); @@ -390,7 +404,7 @@ class GlobalSearch extends Component 'link' => $db->link(), 'project' => $db->environment->project->name ?? null, 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' mariadb '.$db->description.' database databases db'), + 'search_text' => strtolower($db->name.' '.$db->uuid.' mariadb '.$db->description.' database databases db'), ]; }) ); @@ -411,7 +425,7 @@ class GlobalSearch extends Component 'link' => $db->link(), 'project' => $db->environment->project->name ?? null, 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' mongodb '.$db->description.' database databases db'), + 'search_text' => strtolower($db->name.' '.$db->uuid.' mongodb '.$db->description.' database databases db'), ]; }) ); @@ -432,7 +446,7 @@ class GlobalSearch extends Component 'link' => $db->link(), 'project' => $db->environment->project->name ?? null, 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' redis '.$db->description.' database databases db'), + 'search_text' => strtolower($db->name.' '.$db->uuid.' redis '.$db->description.' database databases db'), ]; }) ); @@ -453,7 +467,7 @@ class GlobalSearch extends Component 'link' => $db->link(), 'project' => $db->environment->project->name ?? null, 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' keydb '.$db->description.' database databases db'), + 'search_text' => strtolower($db->name.' '.$db->uuid.' keydb '.$db->description.' database databases db'), ]; }) ); @@ -474,7 +488,7 @@ class GlobalSearch extends Component 'link' => $db->link(), 'project' => $db->environment->project->name ?? null, 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' dragonfly '.$db->description.' database databases db'), + 'search_text' => strtolower($db->name.' '.$db->uuid.' dragonfly '.$db->description.' database databases db'), ]; }) ); @@ -495,7 +509,7 @@ class GlobalSearch extends Component 'link' => $db->link(), 'project' => $db->environment->project->name ?? null, 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' clickhouse '.$db->description.' database databases db'), + 'search_text' => strtolower($db->name.' '.$db->uuid.' clickhouse '.$db->description.' database databases db'), ]; }) ); From 4afe13190527a39a11fdaf5122a7118c6d5c1de4 Mon Sep 17 00:00:00 2001 From: Alberto Rizzi Date: Sun, 12 Jul 2026 13:46:14 +0200 Subject: [PATCH 2/4] feat(domains): add per-domain noindex support Flagged domains are served with X-Robots-Tag: noindex, nofollow via Traefik and Caddy routing labels, so an auto-generated technical domain can be excluded from indexing while the production domain on the same resource stays indexable. --- .../UpdateServiceApplicationFromApi.php | 5 + .../Api/ApplicationsController.php | 18 +- .../Api/ServiceApplicationsController.php | 10 ++ app/Livewire/Project/Application/General.php | 27 +++ app/Livewire/Project/Service/EditDomain.php | 28 +++ app/Models/Application.php | 7 +- app/Models/Service.php | 3 +- app/Models/ServiceApplication.php | 14 +- app/Support/ValidationPatterns.php | 2 +- app/Traits/HasNoindexDomains.php | 63 +++++++ bootstrap/helpers/api.php | 2 + bootstrap/helpers/docker.php | 66 ++++++- bootstrap/helpers/parsers.php | 29 ++- bootstrap/helpers/shared.php | 20 ++- ..._applications_and_service_applications.php | 30 ++++ .../project/application/general.blade.php | 19 ++ .../project/service/edit-domain.blade.php | 20 +++ .../Feature/ApplicationNoindexDomainsTest.php | 98 ++++++++++ .../Service/EditDomainPortValidationTest.php | 11 ++ tests/Unit/FqdnLabelsNoindexTest.php | 168 ++++++++++++++++++ 20 files changed, 617 insertions(+), 23 deletions(-) create mode 100644 app/Traits/HasNoindexDomains.php create mode 100644 database/migrations/2026_07_12_090000_add_noindex_domains_to_applications_and_service_applications.php create mode 100644 tests/Feature/ApplicationNoindexDomainsTest.php create mode 100644 tests/Unit/FqdnLabelsNoindexTest.php diff --git a/app/Actions/Service/UpdateServiceApplicationFromApi.php b/app/Actions/Service/UpdateServiceApplicationFromApi.php index 7bc04dfdb..9d97c4738 100644 --- a/app/Actions/Service/UpdateServiceApplicationFromApi.php +++ b/app/Actions/Service/UpdateServiceApplicationFromApi.php @@ -59,6 +59,11 @@ class UpdateServiceApplicationFromApi $serviceApplication->fqdn = $parsed['normalized']; } + if (array_key_exists('noindex_domains', $payload)) { + // Must run after fqdn is set above: flags are kept only for current domains. + $serviceApplication->setNoindexDomains($payload['noindex_domains'] ?? []); + } + if (array_key_exists('human_name', $payload)) { $serviceApplication->human_name = $payload['human_name']; } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 0f853642d..318101661 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -220,6 +220,7 @@ class ApplicationsController extends Controller 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], + 'noindex_domains' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'The subset of the application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the application domains are ignored.'], 'git_commit_sha' => ['type' => 'string', 'description' => 'The git commit SHA.'], 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], @@ -389,6 +390,7 @@ class ApplicationsController extends Controller 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], + 'noindex_domains' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'The subset of the application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the application domains are ignored.'], 'git_commit_sha' => ['type' => 'string', 'description' => 'The git commit SHA.'], 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], @@ -557,6 +559,7 @@ class ApplicationsController extends Controller 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], + 'noindex_domains' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'The subset of the application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the application domains are ignored.'], 'git_commit_sha' => ['type' => 'string', 'description' => 'The git commit SHA.'], 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], @@ -723,6 +726,7 @@ class ApplicationsController extends Controller 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], + 'noindex_domains' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'The subset of the application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the application domains are ignored.'], 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], 'ports_mappings' => ['type' => 'string', 'description' => 'The ports mappings.'], @@ -862,6 +866,7 @@ class ApplicationsController extends Controller 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], + 'noindex_domains' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'The subset of the application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the application domains are ignored.'], 'ports_mappings' => ['type' => 'string', 'description' => 'The ports mappings.'], 'health_check_enabled' => ['type' => 'boolean', 'description' => 'Health check enabled.'], 'health_check_path' => ['type' => 'string', 'description' => 'Health check path.'], @@ -981,7 +986,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', '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', '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']; + $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', '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']; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -2342,6 +2347,7 @@ class ApplicationsController extends Controller 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], + 'noindex_domains' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'The subset of the application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the application domains are ignored.'], 'git_commit_sha' => ['type' => 'string', 'description' => 'The git commit SHA.'], 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], @@ -2495,7 +2501,7 @@ class ApplicationsController extends Controller $this->authorize('update', $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', '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', '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', 'include_source_commit_in_build']; + $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', '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', 'include_source_commit_in_build']; $validationRules = [ 'name' => 'string|max:255', @@ -2788,8 +2794,14 @@ class ApplicationsController extends Controller if ($dockerComposeDomainsJson->count() > 0) { data_set($data, 'docker_compose_domains', json_encode($dockerComposeDomainsJson)); } + $requestHasNoindexDomains = $request->has('noindex_domains'); + data_forget($data, 'noindex_domains'); $application->fill($data); - if ($application->settings->is_container_label_readonly_enabled && $requestHasDomains && $server->isProxyShouldRun()) { + if ($requestHasNoindexDomains) { + // Must run after fqdn is filled: flags are kept only for domains the app still has. + $application->setNoindexDomains($request->input('noindex_domains') ?? []); + } + if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains) && $server->isProxyShouldRun()) { $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); } $application->save(); diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index a144b3ea6..5947127fb 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -242,6 +242,13 @@ class ServiceApplicationsController extends Controller nullable: true, description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.' ), + 'noindex_domains' => new OA\Property( + property: 'noindex_domains', + type: 'array', + items: new OA\Items(type: 'string'), + description: 'The subset of the service application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the domains are ignored.', + nullable: true, + ), 'human_name' => new OA\Property(property: 'human_name', type: 'string', nullable: true), 'description' => new OA\Property(property: 'description', type: 'string', nullable: true), 'image' => new OA\Property(property: 'image', type: 'string', nullable: true), @@ -313,6 +320,7 @@ class ServiceApplicationsController extends Controller $allowedFields = [ 'url', + 'noindex_domains', 'human_name', 'description', 'image', @@ -324,6 +332,8 @@ class ServiceApplicationsController extends Controller $validationRules = [ 'url' => 'nullable|string', + 'noindex_domains' => 'sometimes|array|nullable', + 'noindex_domains.*' => 'string', 'human_name' => 'nullable|string|max:255', 'description' => 'nullable|string', 'image' => 'nullable|string', diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 289c8eeb0..9468e8944 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -29,6 +29,9 @@ class General extends Component public ?string $fqdn = null; + /** @var array */ + public array $noindexDomains = []; + public string $gitRepository; public string $gitBranch; @@ -142,6 +145,8 @@ class General extends Component 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'fqdn' => ValidationPatterns::applicationDomainRules(), + 'noindexDomains' => 'array', + 'noindexDomains.*' => 'string', 'parsedServiceDomains.*.domain' => ValidationPatterns::applicationDomainRules(), 'gitRepository' => 'required', 'gitBranch' => ['required', 'string', new ValidGitBranch], @@ -351,6 +356,7 @@ class General extends Component $this->application->name = $this->name; $this->application->description = $this->description; $this->application->fqdn = $this->fqdn; + $this->application->setNoindexDomains($this->noindexDomains); $this->application->git_repository = $this->gitRepository; $this->application->git_branch = $this->gitBranch; $this->application->git_commit_sha = $this->gitCommitSha; @@ -403,6 +409,7 @@ class General extends Component $this->name = $this->application->name; $this->description = $this->application->description; $this->fqdn = $this->application->fqdn; + $this->noindexDomains = $this->application->noindexDomains()->all(); $this->gitRepository = $this->application->git_repository; $this->gitBranch = $this->application->git_branch; $this->gitCommitSha = $this->application->git_commit_sha; @@ -731,6 +738,26 @@ class General extends Component $this->submit(); } + public function getConfiguredDomainsProperty(): array + { + return ValidationPatterns::applicationDomainList($this->application->fqdn); + } + + public function updateNoindexDomains() + { + $this->authorize('update', $this->application); + + try { + $this->application->setNoindexDomains($this->noindexDomains); + $this->application->save(); + $this->noindexDomains = $this->application->noindexDomains()->all(); + $this->resetDefaultLabels(); + $this->dispatch('success', 'Search engine indexing updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function setRedirect() { $this->authorize('update', $this->application); diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index 96fe6a62c..add9e6cac 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -31,13 +31,39 @@ class EditDomain extends Component #[Validate] public ?string $fqdn = null; + /** @var array */ + public array $noindexDomains = []; + protected function rules(): array { return [ 'fqdn' => ValidationPatterns::applicationDomainRules(), + 'noindexDomains' => 'array', + 'noindexDomains.*' => 'string', ]; } + public function getConfiguredDomainsProperty(): array + { + return ValidationPatterns::applicationDomainList($this->application->fqdn); + } + + public function updateNoindexDomains() + { + try { + $this->authorize('update', $this->application); + $this->application->setNoindexDomains($this->noindexDomains); + $this->application->save(); + $this->application->refresh(); + $this->syncData(); + $this->application->service->parse(); + $this->dispatch('configurationChanged'); + $this->dispatch('success', 'Search engine indexing updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function mount() { $this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId); @@ -53,11 +79,13 @@ class EditDomain extends Component // Sync to model $this->application->fqdn = $this->fqdn; + $this->application->setNoindexDomains($this->noindexDomains); $this->application->save(); } else { // Sync from model $this->fqdn = $this->application->fqdn; + $this->noindexDomains = $this->application->noindexDomains()->all(); } } diff --git a/app/Models/Application.php b/app/Models/Application.php index d46e4366b..bcfb6d3f8 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -10,6 +10,7 @@ use App\Services\DeploymentConfiguration\ConfigurationDiffer; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; use App\Traits\HasMetrics; +use App\Traits\HasNoindexDomains; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -34,6 +35,7 @@ use Symfony\Component\Yaml\Yaml; 'uuid' => ['type' => 'string', 'description' => 'The application UUID.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], 'fqdn' => ['type' => 'string', 'nullable' => true, 'description' => 'The application domains.'], + 'noindex_domains' => ['type' => 'array', 'items' => ['type' => 'string'], 'nullable' => true, 'description' => 'The subset of the application domains served with an X-Robots-Tag: noindex, nofollow response header.'], 'config_hash' => ['type' => 'string', 'description' => 'Configuration hash.'], 'git_repository' => ['type' => 'string', 'description' => 'Git repository URL.'], 'git_branch' => ['type' => 'string', 'description' => 'Git branch.'], @@ -116,7 +118,7 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { - use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; private static $parserVersion = '5'; @@ -124,6 +126,7 @@ class Application extends BaseModel 'name', 'description', 'fqdn', + 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', @@ -241,6 +244,7 @@ class Application extends BaseModel 'manual_webhook_secret_gitlab' => 'encrypted', 'manual_webhook_secret_bitbucket' => 'encrypted', 'manual_webhook_secret_gitea' => 'encrypted', + 'noindex_domains' => 'array', 'restart_count' => 'integer', 'max_restart_count' => 'integer', 'last_restart_at' => 'datetime', @@ -268,6 +272,7 @@ class Application extends BaseModel $application->fqdn = null; } $payload['fqdn'] = $application->fqdn; + $application->syncNoindexDomains(); } if ($application->isDirty('install_command')) { $payload['install_command'] = str($application->install_command)->trim(); diff --git a/app/Models/Service.php b/app/Models/Service.php index 89438053d..fbdafd26c 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -94,6 +94,7 @@ class Service extends BaseModel { $domains = $this->applications()->get()->pluck('fqdn')->sort()->toArray(); $domains = implode(',', $domains); + $noindexDomains = $this->applications()->get()->pluck('noindex_domains')->flatten()->filter()->sort()->implode(','); $applicationImages = $this->applications()->get()->pluck('image')->sort(); $databaseImages = $this->databases()->get()->pluck('image')->sort(); @@ -104,7 +105,7 @@ class Service extends BaseModel $databaseStorages = $this->databases()->get()->pluck('persistentStorages')->flatten()->sortBy('id'); $storages = $applicationStorages->merge($databaseStorages)->implode('updated_at'); - $newConfigHash = $images.$domains.$images.$storages; + $newConfigHash = $images.$domains.$images.$storages.$noindexDomains; $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 6bf12f4e7..a490d7258 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\HasNoindexDomains; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; @@ -9,7 +10,7 @@ use Symfony\Component\Yaml\Yaml; class ServiceApplication extends BaseModel { - use HasFactory, SoftDeletes; + use HasFactory, HasNoindexDomains, SoftDeletes; protected $fillable = [ 'service_id', @@ -17,6 +18,7 @@ class ServiceApplication extends BaseModel 'human_name', 'description', 'fqdn', + 'noindex_domains', 'ports', 'exposes', 'status', @@ -42,9 +44,19 @@ class ServiceApplication extends BaseModel if ($service->isDirty('status')) { $service->last_online_at = now(); } + if ($service->isDirty('fqdn')) { + $service->syncNoindexDomains(); + } }); } + protected function casts(): array + { + return [ + 'noindex_domains' => 'array', + ]; + } + public function restart() { $container_id = $this->name.'-'.$this->service->uuid; diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index af75098b5..41b27f9ff 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -598,7 +598,7 @@ class ValidationPatterns * Normalize URL components that are case-insensitive while preserving * case-sensitive path, query, and fragment components. */ - private static function normalizeApplicationDomainUrl(string $url): string + public static function normalizeApplicationDomainUrl(string $url): string { $components = parse_url($url); diff --git a/app/Traits/HasNoindexDomains.php b/app/Traits/HasNoindexDomains.php new file mode 100644 index 000000000..c3ba8a7d8 --- /dev/null +++ b/app/Traits/HasNoindexDomains.php @@ -0,0 +1,63 @@ +|null $noindex_domains + */ +trait HasNoindexDomains +{ + public function noindexDomains(): Collection + { + return collect($this->noindex_domains ?? []) + ->filter(fn ($domain) => is_string($domain) && filled($domain)) + ->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain)) + ->unique() + ->values(); + } + + public function isDomainNoindexed(string $domain): bool + { + return $this->noindexDomains()->contains( + ValidationPatterns::normalizeApplicationDomainUrl($domain) + ); + } + + public function setNoindexDomains(iterable $domains): void + { + $this->noindex_domains = collect($domains) + ->filter(fn ($domain) => is_string($domain) && filled($domain)) + ->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain)) + ->intersect($this->currentDomains()) + ->unique() + ->values() + ->all(); + } + + /** + * Drops flags for domains the resource no longer has. Without this, re-adding + * a removed domain later would silently resurrect its old flag. + */ + public function syncNoindexDomains(): void + { + if (blank($this->noindex_domains)) { + return; + } + + $this->setNoindexDomains($this->noindex_domains); + } + + private function currentDomains(): Collection + { + return collect(ValidationPatterns::applicationDomainList($this->fqdn)) + ->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain)); + } +} diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index e430e7364..fd401e1bd 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -119,6 +119,8 @@ function sharedDataApplications() 'is_preview_deployments_enabled' => 'boolean', 'static_image' => Rule::enum(StaticImageTypes::class), 'domains' => ValidationPatterns::applicationDomainRules(), + 'noindex_domains' => 'array|nullable', + 'noindex_domains.*' => 'string', 'redirect' => Rule::enum(RedirectTypes::class), 'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(), diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 0440ae352..c35aaffa7 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -5,6 +5,7 @@ use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\Server; use App\Models\ServiceApplication; +use App\Support\ValidationPatterns; use Illuminate\Support\Collection; use Illuminate\Support\Str; use Spatie\Url\Url; @@ -387,7 +388,21 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource) return $payload; } -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) +/** + * @param Collection|null $noindex_domains + */ +function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool +{ + if (blank($noindex_domains)) { + return false; + } + + return $noindex_domains + ->map(fn (string $noindex_domain) => ValidationPatterns::normalizeApplicationDomainUrl($noindex_domain)) + ->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) { $labels = collect([]); if ($serviceLabels) { @@ -419,7 +434,14 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, $port = $predefinedPort; } $labels->push("caddy_{$loop}={$schema}://{$host}"); - $labels->push("caddy_{$loop}.header=-Server"); + if (isNoindexDomain($domain, $noindex_domains)) { + // Caddy's header directive takes either inline arguments or a block, + // never both, so -Server has to move into the block alongside it. + $labels->push("caddy_{$loop}.header.0_-Server="); + $labels->push("caddy_{$loop}.header.1_X-Robots-Tag=\"noindex, nofollow\""); + } else { + $labels->push("caddy_{$loop}.header=-Server"); + } $labels->push("caddy_{$loop}.try_files={path} /index.html /index.php"); if ($port) { @@ -445,7 +467,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, return $labels->sort(); } -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) +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) { $labels = collect([]); $labels->push('traefik.enable=true'); @@ -518,6 +540,12 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $labels->push("caddy_{$loop}.handle_path.{$loop}_redir-ghost-{$uuid}.rewrite.replacement=/$1"); } + $noindex_name = "{$loop}-{$uuid}-noindex"; + $is_noindex = isNoindexDomain($domain, $noindex_domains); + if ($is_noindex) { + $labels->push("traefik.http.middlewares.{$noindex_name}.headers.customresponseheaders.X-Robots-Tag=noindex, nofollow"); + } + $to_www_name = "{$loop}-{$uuid}-to-www"; $to_non_www_name = "{$loop}-{$uuid}-to-non-www"; $redirect_to_non_www = [ @@ -562,6 +590,9 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ if ($is_http_basic_auth_enabled) { $middlewares->push($http_basic_auth_label); } + if ($is_noindex) { + $middlewares->push($noindex_name); + } $middlewares_from_labels->each(function ($middleware_name) use ($middlewares) { $middlewares->push($middleware_name); }); @@ -588,6 +619,9 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ if ($is_http_basic_auth_enabled) { $middlewares->push($http_basic_auth_label); } + if ($is_noindex) { + $middlewares->push($noindex_name); + } $middlewares_from_labels->each(function ($middleware_name) use ($middlewares) { $middlewares->push($middleware_name); }); @@ -606,8 +640,15 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $labels->push("traefik.http.services.{$http_label}.loadbalancer.server.port=$port"); $labels->push("traefik.http.routers.{$http_label}.service={$http_label}"); } + $middlewares = collect([]); + if ($is_noindex) { + $middlewares->push($noindex_name); + } if ($is_force_https_enabled) { - $labels->push("traefik.http.routers.{$http_label}.middlewares=redirect-to-https"); + $middlewares->push('redirect-to-https'); + } + if ($middlewares->isNotEmpty()) { + $labels->push("traefik.http.routers.{$http_label}.middlewares={$middlewares->join(',')}"); } } else { // Set labels for http @@ -640,6 +681,9 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ if ($is_http_basic_auth_enabled) { $middlewares->push($http_basic_auth_label); } + if ($is_noindex) { + $middlewares->push($noindex_name); + } $middlewares_from_labels->each(function ($middleware_name) use ($middlewares) { $middlewares->push($middleware_name); }); @@ -666,6 +710,9 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ if ($is_http_basic_auth_enabled) { $middlewares->push($http_basic_auth_label); } + if ($is_noindex) { + $middlewares->push($noindex_name); + } $middlewares_from_labels->each(function ($middleware_name) use ($middlewares) { $middlewares->push($middleware_name); }); @@ -698,6 +745,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview if ($pull_request_id === 0) { if ($application->fqdn) { $domains = str(data_get($application, 'fqdn'))->explode(','); + $noindexDomains = $application->noindexDomains(); $shouldGenerateLabelsExactly = $application->destination->server->settings->generate_exact_labels; if ($shouldGenerateLabelsExactly) { switch ($application->destination->server->proxyType()) { @@ -713,6 +761,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, + noindex_domains: $noindexDomains, )); break; case ProxyTypes::CADDY->value: @@ -728,6 +777,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, + noindex_domains: $noindexDomains, )); break; } @@ -743,6 +793,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, + noindex_domains: $noindexDomains, )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, @@ -756,6 +807,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, + noindex_domains: $noindexDomains, )); } } @@ -765,6 +817,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview } else { $domains = collect([]); } + // Previews are ephemeral: every domain is noindex. + $noindexDomains = $domains; $shouldGenerateLabelsExactly = $application->destination->server->settings->generate_exact_labels; if ($shouldGenerateLabelsExactly) { switch ($application->destination->server->proxyType()) { @@ -779,6 +833,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, + noindex_domains: $noindexDomains, )); break; case ProxyTypes::CADDY->value: @@ -793,6 +848,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, + noindex_domains: $noindexDomains, )); break; } @@ -807,6 +863,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, + noindex_domains: $noindexDomains, )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, @@ -819,6 +876,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, + noindex_domains: $noindexDomains, )); } } diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index ff1d6563e..76b02eaf7 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1352,6 +1352,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($isPullRequest) { $labelNetwork = "{$resource->destination->network}-{$pullRequestId}"; } + $noindexDomains = $isPullRequest ? $fqdns : $originalResource->noindexDomains(); if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: @@ -1363,7 +1364,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, - image: $image + image: $image, + noindex_domains: $noindexDomains )); break; case ProxyTypes::CADDY->value: @@ -1377,7 +1379,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, - predefinedPort: $predefinedPort + predefinedPort: $predefinedPort, + noindex_domains: $noindexDomains )); break; } @@ -1390,7 +1393,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, - image: $image + image: $image, + noindex_domains: $noindexDomains )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $labelNetwork, @@ -1402,7 +1406,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, - predefinedPort: $predefinedPort + predefinedPort: $predefinedPort, + noindex_domains: $noindexDomains )); } } @@ -2546,6 +2551,10 @@ function serviceParser(Service $resource): Collection } else { $fqdns = collect(data_get($savedService, 'fqdns'))->filter(); } + // Flags live on the ServiceApplication; a ServiceDatabase has no domains. + $noindexDomains = $savedService instanceof ServiceApplication + ? $savedService->noindexDomains() + : collect([]); $defaultLabels = defaultLabels( id: $resource->id, @@ -2626,7 +2635,8 @@ function serviceParser(Service $resource): Collection is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, - image: $image + image: $image, + noindex_domains: $noindexDomains )); break; case ProxyTypes::CADDY->value: @@ -2640,7 +2650,8 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, - predefinedPort: $predefinedPort + predefinedPort: $predefinedPort, + noindex_domains: $noindexDomains )); break; } @@ -2653,7 +2664,8 @@ function serviceParser(Service $resource): Collection is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, - image: $image + image: $image, + noindex_domains: $noindexDomains )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $network, @@ -2665,7 +2677,8 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, - predefinedPort: $predefinedPort + predefinedPort: $predefinedPort, + noindex_domains: $noindexDomains )); } } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 10de1f86f..66a53b388 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -2679,6 +2679,9 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } else { $fqdns = collect(data_get($savedService, 'fqdns'))->filter(); } + $noindexDomains = $savedService instanceof ServiceApplication + ? $savedService->noindexDomains() + : collect([]); $defaultLabels = defaultLabels( id: $resource->id, name: $containerName, @@ -2705,7 +2708,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, - image: data_get($service, 'image') + image: data_get($service, 'image'), + noindex_domains: $noindexDomains )); break; case ProxyTypes::CADDY->value: @@ -2718,7 +2722,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, - image: data_get($service, 'image') + image: data_get($service, 'image'), + noindex_domains: $noindexDomains )); break; } @@ -2731,7 +2736,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, - image: data_get($service, 'image') + image: data_get($service, 'image'), + noindex_domains: $noindexDomains )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $resource->destination->network, @@ -2742,7 +2748,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, - image: data_get($service, 'image') + image: data_get($service, 'image'), + noindex_domains: $noindexDomains )); } } @@ -3467,6 +3474,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal }); } } + $noindexDomains = $pull_request_id !== 0 ? $fqdns : $resource->noindexDomains(); $shouldGenerateLabelsExactly = $server->settings->generate_exact_labels; if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { @@ -3481,6 +3489,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + noindex_domains: $noindexDomains, ) ); break; @@ -3495,6 +3504,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + noindex_domains: $noindexDomains, ) ); break; @@ -3510,6 +3520,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + noindex_domains: $noindexDomains, ) ); $serviceLabels = $serviceLabels->merge( @@ -3522,6 +3533,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + noindex_domains: $noindexDomains, ) ); } diff --git a/database/migrations/2026_07_12_090000_add_noindex_domains_to_applications_and_service_applications.php b/database/migrations/2026_07_12_090000_add_noindex_domains_to_applications_and_service_applications.php new file mode 100644 index 000000000..9b39c16ed --- /dev/null +++ b/database/migrations/2026_07_12_090000_add_noindex_domains_to_applications_and_service_applications.php @@ -0,0 +1,30 @@ +json('noindex_domains')->nullable()->after('fqdn'); + }); + + Schema::table('service_applications', function (Blueprint $table) { + $table->json('noindex_domains')->nullable()->after('fqdn'); + }); + } + + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('noindex_domains'); + }); + + Schema::table('service_applications', function (Blueprint $table) { + $table->dropColumn('noindex_domains'); + }); + } +}; diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 190f4262a..acf32c68d 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -126,6 +126,25 @@ @endcan @endif + @if ($application->settings->is_container_label_readonly_enabled && count($this->configuredDomains) > 0) +
+
+

Search Engine Indexing

+ +
+ @foreach ($this->configuredDomains as $domain) + + @endforeach +
+ @endif
@if ($application->settings->is_container_label_readonly_enabled == false) @if ($application->redirect === 'both') diff --git a/resources/views/livewire/project/service/edit-domain.blade.php b/resources/views/livewire/project/service/edit-domain.blade.php index d6e56dd45..c9cf880b8 100644 --- a/resources/views/livewire/project/service/edit-domain.blade.php +++ b/resources/views/livewire/project/service/edit-domain.blade.php @@ -14,6 +14,26 @@ Save + @if (count($this->configuredDomains) > 0) +
+
+

Search Engine Indexing

+ +
+ @foreach ($this->configuredDomains as $domain) + + @endforeach +
+ @endif +
    diff --git a/tests/Feature/ApplicationNoindexDomainsTest.php b/tests/Feature/ApplicationNoindexDomainsTest.php new file mode 100644 index 000000000..1369fab0b --- /dev/null +++ b/tests/Feature/ApplicationNoindexDomainsTest.php @@ -0,0 +1,98 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +describe('Application noindex domains', function () { + test('a flag is kept only for a domain the application actually has', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'fqdn' => 'https://prod.example.com,https://staging.example.com', + ]); + + $application->setNoindexDomains([ + 'https://staging.example.com', + 'https://never-configured.example.com', + ]); + $application->save(); + + expect($application->refresh()->noindexDomains()->all()) + ->toBe(['https://staging.example.com']); + }); + + test('removing a domain prunes its flag', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'fqdn' => 'https://prod.example.com,https://staging.example.com', + 'noindex_domains' => ['https://staging.example.com'], + ]); + + // The staging domain goes away; its flag must not survive to be silently + // resurrected if the same domain is added back later. + $application->fqdn = 'https://prod.example.com'; + $application->save(); + + expect($application->refresh()->noindexDomains()->all())->toBeEmpty(); + }); + + test('flags survive a save that does not touch the domains', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'fqdn' => 'https://prod.example.com,https://staging.example.com', + 'noindex_domains' => ['https://staging.example.com'], + ]); + + $application->name = 'renamed'; + $application->save(); + + expect($application->refresh()->noindexDomains()->all()) + ->toBe(['https://staging.example.com']); + }); + + test('isDomainNoindexed ignores casing', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'fqdn' => 'https://staging.example.com', + 'noindex_domains' => ['https://staging.example.com'], + ]); + + expect($application->isDomainNoindexed('https://Staging.Example.COM'))->toBeTrue(); + expect($application->isDomainNoindexed('https://prod.example.com'))->toBeFalse(); + }); + + test('the Livewire toggle persists the flag', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'fqdn' => 'https://prod.example.com,https://staging.example.com', + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->set('noindexDomains', ['https://staging.example.com']) + ->call('updateNoindexDomains') + ->assertDispatched('success'); + + expect($application->refresh()->noindexDomains()->all()) + ->toBe(['https://staging.example.com']); + }); +}); diff --git a/tests/Feature/Service/EditDomainPortValidationTest.php b/tests/Feature/Service/EditDomainPortValidationTest.php index 1ac409e08..6a0574219 100644 --- a/tests/Feature/Service/EditDomainPortValidationTest.php +++ b/tests/Feature/Service/EditDomainPortValidationTest.php @@ -74,6 +74,17 @@ it('loads the EditDomain component with required port', function () { ->assertOk(); }); +it('marks noindex changes as pending configuration', function () { + $this->service->isConfigurationChanged(save: true); + + Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) + ->set('noindexDomains', ['http://example.com:8000']) + ->call('updateNoindexDomains') + ->assertDispatched('configurationChanged'); + + expect($this->service->refresh()->isConfigurationChanged())->toBeTrue(); +}); + it('shows warning modal when trying to remove required port', function () { Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) ->set('fqdn', 'http://example.com') // Remove port diff --git a/tests/Unit/FqdnLabelsNoindexTest.php b/tests/Unit/FqdnLabelsNoindexTest.php new file mode 100644 index 000000000..48f2f4912 --- /dev/null +++ b/tests/Unit/FqdnLabelsNoindexTest.php @@ -0,0 +1,168 @@ +values()->all(); +} + +function caddyLabels(array $domains, ?array $noindex = null): array +{ + return fqdnLabelsForCaddy( + network: 'testnetwork', + uuid: 'testuuid', + domains: collect($domains), + onlyPort: 3000, + noindex_domains: $noindex === null ? null : collect($noindex), + )->values()->all(); +} + +/** The middleware chain Traefik will apply to a given router. */ +function middlewaresOf(array $labels, string $router): array +{ + $chain = collect($labels)->first( + fn (string $label) => str_starts_with($label, "traefik.http.routers.{$router}.middlewares=") + ); + + if ($chain === null) { + return []; + } + + return explode(',', str($chain)->after('.middlewares=')->toString()); +} + +describe('Traefik noindex middleware', function () { + test('only the flagged domain gets the header', function () { + $labels = traefikLabels( + domains: ['https://prod.example.com', 'https://staging.example.com'], + noindex: ['https://staging.example.com'], + ); + + // The middleware is defined exactly once, for the second domain (loop index 1). + expect($labels)->toContain( + 'traefik.http.middlewares.1-testuuid-noindex.headers.customresponseheaders.X-Robots-Tag=noindex, nofollow' + ); + expect(collect($labels)->filter(fn ($l) => str_contains($l, 'X-Robots-Tag'))->count())->toBe(1); + + // ...and only the flagged domain's router references it. + expect(middlewaresOf($labels, 'https-1-testuuid'))->toContain('1-testuuid-noindex'); + expect(middlewaresOf($labels, 'https-0-testuuid'))->not->toContain('1-testuuid-noindex'); + }); + + test('no flags means byte-identical output to before the feature', function () { + $domains = ['https://example.com', 'http://other.example.com/api']; + + expect(traefikLabels($domains, noindex: null)) + ->toBe(traefikLabels($domains, noindex: [])); + + expect(collect(traefikLabels($domains, noindex: null))->filter( + fn ($l) => str_contains($l, 'noindex') + )->all())->toBeEmpty(); + }); + + test('the header is applied on a subpath route', function () { + $labels = traefikLabels( + domains: ['https://example.com/admin'], + noindex: ['https://example.com/admin'], + ); + + expect(middlewaresOf($labels, 'https-0-testuuid'))->toContain('0-testuuid-noindex'); + }); + + test('the header is applied on a plain http route', function () { + $labels = traefikLabels( + domains: ['http://example.com'], + noindex: ['http://example.com'], + ); + + expect(middlewaresOf($labels, 'http-0-testuuid'))->toContain('0-testuuid-noindex'); + }); + + test('an https domain also gets the header on its http route', function () { + $labels = traefikLabels( + domains: ['https://example.com'], + noindex: ['https://example.com'], + ); + + expect(middlewaresOf($labels, 'http-0-testuuid'))->toContain('0-testuuid-noindex'); + }); + + test('the header wraps the http to https redirect', function () { + $labels = traefikLabels( + domains: ['https://example.com'], + noindex: ['https://example.com'], + forceHttps: true, + ); + + expect(middlewaresOf($labels, 'http-0-testuuid')) + ->toBe(['0-testuuid-noindex', 'redirect-to-https']); + }); + + test('the header is applied on an http subpath route', function () { + $labels = traefikLabels( + domains: ['http://example.com/api'], + noindex: ['http://example.com/api'], + ); + + expect(middlewaresOf($labels, 'http-0-testuuid'))->toContain('0-testuuid-noindex'); + }); + + test('a casing difference does not silently drop the header', function () { + $labels = traefikLabels( + domains: ['https://Example.COM'], + noindex: ['https://example.com'], + ); + + expect(middlewaresOf($labels, 'https-0-testuuid'))->toContain('0-testuuid-noindex'); + }); + + test('a domain that is not configured is ignored', function () { + $labels = traefikLabels( + domains: ['https://example.com'], + noindex: ['https://somewhere-else.com'], + ); + + expect(collect($labels)->filter(fn ($l) => str_contains($l, 'noindex'))->all())->toBeEmpty(); + }); +}); + +describe('Caddy noindex header', function () { + test('the flagged domain uses the header block, the other keeps the inline form', function () { + $labels = caddyLabels( + domains: ['https://prod.example.com', 'https://staging.example.com'], + noindex: ['https://staging.example.com'], + ); + + // Caddy's header directive takes either inline args or a block, never both, + // so the flagged domain has to move -Server into the block alongside the tag. + expect($labels)->toContain('caddy_1.header.0_-Server='); + expect($labels)->toContain('caddy_1.header.1_X-Robots-Tag="noindex, nofollow"'); + expect($labels)->not->toContain('caddy_1.header=-Server'); + + // The unflagged domain is untouched. + expect($labels)->toContain('caddy_0.header=-Server'); + expect(collect($labels)->filter(fn ($l) => str_contains($l, 'caddy_0.header.'))->all())->toBeEmpty(); + }); + + test('no flags means byte-identical output to before the feature', function () { + $domains = ['https://example.com', 'https://other.example.com']; + + expect(caddyLabels($domains, noindex: null)) + ->toBe(caddyLabels($domains, noindex: [])); + + expect(collect(caddyLabels($domains, noindex: null))->filter( + fn ($l) => str_contains($l, 'X-Robots-Tag') + )->all())->toBeEmpty(); + }); +}); From 69ba001e21cd29efb3c579535247e08c34ca635c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:10:30 +0200 Subject: [PATCH 3/4] feat(domains): add inline indexing and redirect controls Move domain indexing and redirect settings into domain management views and include noindex changes in deployment configuration diffs. --- UI_REDESIGN.md | 2 + app/Livewire/Project/Application/Domains.php | 38 ++++++++++ app/Livewire/Project/Application/General.php | 27 -------- app/Livewire/Project/Service/Domains.php | 53 +++++++++++++- app/Livewire/Project/Service/EditDomain.php | 28 -------- app/Models/Application.php | 2 +- .../ApplicationConfigurationSnapshot.php | 1 + .../ConfigurationDiffer.php | 16 ++++- .../views/components/forms/listbox.blade.php | 25 ++++++- .../project/application/domains.blade.php | 69 +++++++++---------- .../project/application/general.blade.php | 19 ----- .../application/partials/domain-row.blade.php | 1 + .../project/service/domains.blade.php | 51 +++++++------- .../project/service/edit-domain.blade.php | 20 ------ .../service/partials/domain-table.blade.php | 8 ++- .../shared/cloudflare-autoconfigure.blade.php | 7 +- .../ApplicationConfigurationChangedTest.php | 36 ++++++++++ tests/Feature/ApplicationDomainsTest.php | 29 ++++++-- .../Feature/ApplicationNoindexDomainsTest.php | 9 ++- .../Livewire/ConfigurationCheckerTest.php | 20 ++++++ .../Service/EditDomainPortValidationTest.php | 6 +- tests/Feature/ServiceDomainsTest.php | 45 ++++++++++-- 22 files changed, 327 insertions(+), 185 deletions(-) diff --git a/UI_REDESIGN.md b/UI_REDESIGN.md index 46b605ebc..bc690cd1c 100644 --- a/UI_REDESIGN.md +++ b/UI_REDESIGN.md @@ -24,6 +24,8 @@ compact divided list, not legacy green check SVGs or fixed-width status rows. > - Build frontend assets in the Vitee container with > `docker exec coolify-vite npm run build`. > - Use existing components before adding another styling abstraction. +> - Use `` for dropdown controls. Never add a native +> ` - - - - +
    +
    @else {{ $redirectLabel }} @@ -384,6 +371,14 @@ @enderror
+ @unless ($labelsAreWritable) + + @endunless + @if ($editDomainDnsFailed) This domain does not currently resolve to this server. diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 769502e44..b3775a1dd 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -71,25 +71,6 @@ - @if ($buildPack !== 'dockercompose' && $application->settings->is_container_label_readonly_enabled && count($this->configuredDomains) > 0) -
-
-

Search engine indexing

- -
- @foreach ($this->configuredDomains as $domain) - - @endforeach -
- @endif @if ($buildPack !== 'dockercompose') diff --git a/resources/views/livewire/project/application/partials/domain-row.blade.php b/resources/views/livewire/project/application/partials/domain-row.blade.php index 6f6446ba0..785a27a9e 100644 --- a/resources/views/livewire/project/application/partials/domain-row.blade.php +++ b/resources/views/livewire/project/application/partials/domain-row.blade.php @@ -107,6 +107,7 @@ index: {{ $index }}, url: @js($row['url']), service: @js($row['service'] ?? null), + indexing: @js($application->isDomainNoindexed($row['url']) ? 'noindex' : 'index'), })" class="icon-button shrink-0" title="Edit domain" aria-label="Edit domain"> diff --git a/resources/views/livewire/project/service/domains.blade.php b/resources/views/livewire/project/service/domains.blade.php index 91d2413ce..d2755b039 100644 --- a/resources/views/livewire/project/service/domains.blade.php +++ b/resources/views/livewire/project/service/domains.blade.php @@ -22,11 +22,15 @@ localEditingIndex: @js($editingIndex), localEditingDomain: @js($editingDomain), localEditingServiceApplicationId: @js($editingServiceApplicationId), - openEditDomain(index, url, serviceApplicationId, serviceLabel) { + localDirection: 'both', + localIndexing: 'index', + openEditDomain(index, url, serviceApplicationId, serviceLabel, direction, indexing) { this.localEditingIndex = index; this.localEditingDomain = url; this.localEditingServiceApplicationId = serviceApplicationId; this.editingServiceLabel = serviceLabel || ''; + this.localDirection = direction || 'both'; + this.localIndexing = indexing || 'index'; this.modalOpen = true; this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.()); }, @@ -41,6 +45,8 @@ $wire.editingIndex = this.localEditingIndex; $wire.editingDomain = this.localEditingDomain; $wire.editingServiceApplicationId = this.localEditingServiceApplicationId; + $wire.editingDirection = this.localDirection; + $wire.editingIndexing = this.localIndexing; $wire.showEditDomainModal = true; }, matchesDomainSearch(value) { @@ -50,7 +56,7 @@ return values.some((value) => this.matchesDomainSearch(value)); }, }" - @open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.serviceApplicationId, $event.detail.serviceLabel)" + @open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.serviceApplicationId, $event.detail.serviceLabel, $event.detail.direction, $event.detail.indexing)" @edit-domain-saved.window="closeEditDomain()"> @can('update', $service) @@ -94,7 +100,9 @@ @endif @can('update', $service) @if ($serviceAppCount > 0) - @include('livewire.project.shared.cloudflare-autoconfigure') +
+ @include('livewire.project.shared.cloudflare-autoconfigure') +
@@ -177,31 +185,12 @@ @php $app = collect($serviceApps)->firstWhere('id', (int) $appId); $heading = \Illuminate\Support\Str::headline($app['name'] ?? $rows->first()['service_name'] ?? 'Service'); - $redirect = $serviceRedirects[$appId] ?? 'both'; - $redirectLabel = match ($redirect) { - 'www' => 'Redirect to www', - 'non-www' => 'Redirect to non-www', - default => 'Allow both', - }; @endphp
-
+
{{ $heading }} - @can('update', $service) -
- -
- @else - {{ $redirectLabel }} - @endcan
@@ -210,7 +199,7 @@ 'domainRows' => $domainRows, 'service' => $service, 'showServiceColumn' => false, - 'showHeader' => false, + 'showHeader' => true, ])
@@ -279,6 +268,20 @@ @enderror +
+ + +
+ @if ($editDomainDnsFailed) This domain does not currently resolve to this server. diff --git a/resources/views/livewire/project/service/edit-domain.blade.php b/resources/views/livewire/project/service/edit-domain.blade.php index 981ac4fbc..52474c8d9 100644 --- a/resources/views/livewire/project/service/edit-domain.blade.php +++ b/resources/views/livewire/project/service/edit-domain.blade.php @@ -16,26 +16,6 @@ - @if (count($this->configuredDomains) > 0) -
-
-

Search Engine Indexing

- -
- @foreach ($this->configuredDomains as $domain) - - @endforeach -
- @endif -
    diff --git a/resources/views/livewire/project/service/partials/domain-table.blade.php b/resources/views/livewire/project/service/partials/domain-table.blade.php index 6f3236b7d..48bd1658e 100644 --- a/resources/views/livewire/project/service/partials/domain-table.blade.php +++ b/resources/views/livewire/project/service/partials/domain-table.blade.php @@ -131,9 +131,11 @@ @click="$dispatch('open-edit-domain', { index: {{ $index }}, url: @js($row['url']), - serviceApplicationId: {{ (int) ($row['service_application_id'] ?? 0) }}, - serviceLabel: @js($serviceLabel), - })" + serviceApplicationId: {{ (int) ($row['service_application_id'] ?? 0) }}, + serviceLabel: @js($serviceLabel), + direction: @js($serviceRedirects[$row['service_application_id']] ?? 'both'), + indexing: @js($service->applications->firstWhere('id', $row['service_application_id'])?->isDomainNoindexed($row['url']) ? 'noindex' : 'index'), + })" class="icon-button shrink-0" title="Edit domain" aria-label="Edit domain"> diff --git a/resources/views/livewire/project/shared/cloudflare-autoconfigure.blade.php b/resources/views/livewire/project/shared/cloudflare-autoconfigure.blade.php index 499ffc54e..43ea25439 100644 --- a/resources/views/livewire/project/shared/cloudflare-autoconfigure.blade.php +++ b/resources/views/livewire/project/shared/cloudflare-autoconfigure.blade.php @@ -8,9 +8,10 @@ x-bind:aria-expanded="dnsEntriesOpen" title="DNS entries for this server"> DNS entries - - - + + +