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:
parent
3da9942bf3
commit
4afe131905
20 changed files with 617 additions and 23 deletions
|
|
@ -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'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
63
app/Traits/HasNoindexDomains.php
Normal file
63
app/Traits/HasNoindexDomains.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
$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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
98
tests/Feature/ApplicationNoindexDomainsTest.php
Normal file
98
tests/Feature/ApplicationNoindexDomainsTest.php
Normal 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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
168
tests/Unit/FqdnLabelsNoindexTest.php
Normal file
168
tests/Unit/FqdnLabelsNoindexTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue