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(); + }); +});