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.
This commit is contained in:
Alberto Rizzi 2026-07-12 13:46:14 +02:00
parent 3da9942bf3
commit 4afe131905
20 changed files with 617 additions and 23 deletions

View file

@ -59,6 +59,11 @@ public function execute(ServiceApplication $serviceApplication, Request $request
$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'];
}

View file

@ -220,6 +220,7 @@ public function applications(Request $request)
'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 @@ public function create_public_application(Request $request)
'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 @@ public function create_private_gh_app_application(Request $request)
'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 @@ public function create_private_deploy_key_application(Request $request)
'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 @@ public function create_dockerfile_application(Request $request)
'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 @@ private function create_application(Request $request, $type)
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 @@ public function delete_by_uuid(Request $request)
'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 @@ public function update_by_uuid(Request $request)
$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 @@ public function update_by_uuid(Request $request)
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();

View file

@ -242,6 +242,13 @@ public function show(Request $request): JsonResponse
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 @@ public function update(Request $request, UpdateServiceApplicationFromApi $update
$allowedFields = [
'url',
'noindex_domains',
'human_name',
'description',
'image',
@ -324,6 +332,8 @@ public function update(Request $request, UpdateServiceApplicationFromApi $update
$validationRules = [
'url' => 'nullable|string',
'noindex_domains' => 'sometimes|array|nullable',
'noindex_domains.*' => 'string',
'human_name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'image' => 'nullable|string',

View file

@ -29,6 +29,9 @@ class General extends Component
public ?string $fqdn = null;
/** @var array<int, string> */
public array $noindexDomains = [];
public string $gitRepository;
public string $gitBranch;
@ -142,6 +145,8 @@ protected function rules(): array
'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 @@ public function syncData(bool $toModel = false): void
$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 @@ public function syncData(bool $toModel = false): void
$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 @@ public function confirmDomainUsage()
$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);

View file

@ -31,13 +31,39 @@ class EditDomain extends Component
#[Validate]
public ?string $fqdn = null;
/** @var array<int, string> */
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 @@ public function syncData(bool $toModel = false): void
// 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();
}
}

View file

@ -10,6 +10,7 @@
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 @@
'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 @@
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 @@ protected function casts(): array
'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 @@ protected static function booted()
$application->fqdn = null;
}
$payload['fqdn'] = $application->fqdn;
$application->syncNoindexDomains();
}
if ($application->isDirty('install_command')) {
$payload['install_command'] = str($application->install_command)->trim();

View file

@ -94,6 +94,7 @@ public function isConfigurationChanged(bool $save = false)
{
$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 @@ public function isConfigurationChanged(bool $save = false)
$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');

View file

@ -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 @@
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 @@ protected static function booted()
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;

View file

@ -598,7 +598,7 @@ public static function normalizeApplicationDomains(?string $value): ?string
* 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);

View file

@ -0,0 +1,63 @@
<?php
namespace App\Traits;
use App\Support\ValidationPatterns;
use Illuminate\Support\Collection;
/**
* Flags the subset of a resource's `fqdn` domains that must not be indexed.
*
* Domains are normalized on write and on compare, so a casing difference
* between the stored flag and the configured domain cannot silently drop it.
*
* @property array<int, string>|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));
}
}

View file

@ -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(),

View file

@ -5,6 +5,7 @@
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<int, string>|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}");
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,
));
}
}

View file

@ -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
));
}
}

View file

@ -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,
)
);
}

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('applications', function (Blueprint $table) {
$table->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');
});
}
};

View file

@ -126,6 +126,25 @@
@endcan
@endif
</div>
@if ($application->settings->is_container_label_readonly_enabled && count($this->configuredDomains) > 0)
<div class="flex flex-col gap-2 pt-2">
<div class="flex items-center gap-2">
<h4>Search Engine Indexing</h4>
<x-helper
helper="Checked domains are served with an <span class='text-helper'>X-Robots-Tag: noindex, nofollow</span> response header, which keeps them out of search engines.<br><br>Useful for auto-generated or staging domains you do not want indexed, while your production domain stays indexable.<br><br>This header overrides any X-Robots-Tag your application sets itself.<br><br>Preview deployments are always noindex." />
</div>
@foreach ($this->configuredDomains as $domain)
<label
class="form-control flex max-w-full cursor-pointer flex-row items-center gap-4 py-1 pr-2 dark:hover:bg-coolgray-100">
<span class="flex min-w-0 grow gap-2 break-words">{{ $domain }}</span>
<input type="checkbox" value="{{ $domain }}" wire:model="noindexDomains"
wire:change="updateNoindexDomains" wire:loading.attr="disabled"
x-bind:disabled="!canUpdate"
class="shrink-0 cursor-pointer rounded-sm text-coolgray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs focus-visible:ring-offset-2 dark:border-neutral-700 dark:bg-coolgray-100 dark:focus-visible:ring-warning dark:focus-visible:ring-offset-base dark:disabled:cursor-not-allowed dark:disabled:bg-base" />
</label>
@endforeach
</div>
@endif
<div class="flex items-end gap-2">
@if ($application->settings->is_container_label_readonly_enabled == false)
@if ($application->redirect === 'both')

View file

@ -14,6 +14,26 @@
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
</form>
@if (count($this->configuredDomains) > 0)
<div class="flex flex-col gap-2 pt-4">
<div class="flex items-center gap-2">
<h4>Search Engine Indexing</h4>
<x-helper
helper="Checked domains are served with an <span class='text-helper'>X-Robots-Tag: noindex, nofollow</span> response header, which keeps them out of search engines.<br><br>Useful for auto-generated or staging domains you do not want indexed, while your production domain stays indexable.<br><br>This header overrides any X-Robots-Tag your service sets itself." />
</div>
@foreach ($this->configuredDomains as $domain)
<label
class="form-control flex max-w-full cursor-pointer flex-row items-center gap-4 py-1 pr-2 dark:hover:bg-coolgray-100">
<span class="flex min-w-0 grow gap-2 break-words">{{ $domain }}</span>
<input type="checkbox" value="{{ $domain }}" wire:model="noindexDomains"
wire:change="updateNoindexDomains" wire:loading.attr="disabled"
@cannot('update', $application) disabled @endcannot
class="shrink-0 cursor-pointer rounded-sm text-coolgray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs focus-visible:ring-offset-2 dark:border-neutral-700 dark:bg-coolgray-100 dark:focus-visible:ring-warning dark:focus-visible:ring-offset-base dark:disabled:cursor-not-allowed dark:disabled:bg-base" />
</label>
@endforeach
</div>
@endif
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal" confirmAction="confirmDomainUsage">
<x-slot:consequences>
<ul class="mt-2 ml-4 list-disc">

View file

@ -0,0 +1,98 @@
<?php
use App\Livewire\Project\Application\General;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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']);
});
});

View file

@ -74,6 +74,17 @@ function get_service_templates_mock()
->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

View file

@ -0,0 +1,168 @@
<?php
/**
* Per-domain noindex labels.
*
* The X-Robots-Tag header must land on the router of the flagged domain only,
* leaving every other domain of the same resource indexable.
*/
function traefikLabels(array $domains, ?array $noindex = null, bool $forceHttps = false): array
{
return fqdnLabelsForTraefik(
uuid: 'testuuid',
domains: collect($domains),
onlyPort: 3000,
is_force_https_enabled: $forceHttps,
noindex_domains: $noindex === null ? null : collect($noindex),
)->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();
});
});