mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 00:23:27 +00:00
feat(domains): add per-domain noindex support (#10908)
This commit is contained in:
@@ -24,6 +24,8 @@ compact divided list, not legacy green check SVGs or fixed-width status rows.
|
||||
> - Build frontend assets in the Vitee container with
|
||||
> `docker exec coolify-vite npm run build`.
|
||||
> - Use existing components before adding another styling abstraction.
|
||||
> - Use `<x-forms.listbox>` for dropdown controls. Never add a native
|
||||
> `<select>` to a redesigned view, including compact table-row controls.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -59,6 +59,11 @@ class UpdateServiceApplicationFromApi
|
||||
$serviceApplication->fqdn = $parsed['normalized'];
|
||||
}
|
||||
|
||||
if (array_key_exists('noindex_domains', $payload)) {
|
||||
// Must run after fqdn is set above: flags are kept only for current domains.
|
||||
$serviceApplication->setNoindexDomains($payload['noindex_domains'] ?? []);
|
||||
}
|
||||
|
||||
if (array_key_exists('human_name', $payload)) {
|
||||
$serviceApplication->human_name = $payload['human_name'];
|
||||
}
|
||||
|
||||
@@ -302,6 +302,7 @@ class ApplicationsController extends Controller
|
||||
'name' => ['type' => 'string', 'description' => 'The application name.'],
|
||||
'description' => ['type' => 'string', 'description' => 'The application description.'],
|
||||
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
|
||||
'noindex_domains' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'The subset of the application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the application domains are ignored.'],
|
||||
'git_commit_sha' => ['type' => 'string', 'description' => 'The git commit SHA.'],
|
||||
'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'],
|
||||
'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'],
|
||||
@@ -496,6 +497,7 @@ class ApplicationsController extends Controller
|
||||
'name' => ['type' => 'string', 'description' => 'The application name.'],
|
||||
'description' => ['type' => 'string', 'description' => 'The application description.'],
|
||||
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
|
||||
'noindex_domains' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'The subset of the application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the application domains are ignored.'],
|
||||
'git_commit_sha' => ['type' => 'string', 'description' => 'The git commit SHA.'],
|
||||
'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'],
|
||||
'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'],
|
||||
@@ -689,6 +691,7 @@ class ApplicationsController extends Controller
|
||||
'name' => ['type' => 'string', 'description' => 'The application name.'],
|
||||
'description' => ['type' => 'string', 'description' => 'The application description.'],
|
||||
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
|
||||
'noindex_domains' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'The subset of the application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the application domains are ignored.'],
|
||||
'git_commit_sha' => ['type' => 'string', 'description' => 'The git commit SHA.'],
|
||||
'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'],
|
||||
'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'],
|
||||
@@ -880,6 +883,7 @@ class ApplicationsController extends Controller
|
||||
'name' => ['type' => 'string', 'description' => 'The application name.'],
|
||||
'description' => ['type' => 'string', 'description' => 'The application description.'],
|
||||
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
|
||||
'noindex_domains' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'The subset of the application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the application domains are ignored.'],
|
||||
'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'],
|
||||
'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'],
|
||||
'ports_mappings' => ['type' => 'string', 'description' => 'The ports mappings.'],
|
||||
@@ -1043,6 +1047,7 @@ class ApplicationsController extends Controller
|
||||
'name' => ['type' => 'string', 'description' => 'The application name.'],
|
||||
'description' => ['type' => 'string', 'description' => 'The application description.'],
|
||||
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
|
||||
'noindex_domains' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'The subset of the application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the application domains are ignored.'],
|
||||
'ports_mappings' => ['type' => 'string', 'description' => 'The ports mappings.'],
|
||||
'health_check_enabled' => ['type' => 'boolean', 'description' => 'Health check enabled.'],
|
||||
'health_check_path' => ['type' => 'string', 'description' => 'Health check path.'],
|
||||
@@ -1186,7 +1191,7 @@ class ApplicationsController extends Controller
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', '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', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS];
|
||||
$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', 'use_build_secrets', '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', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255',
|
||||
@@ -2610,6 +2615,7 @@ class ApplicationsController extends Controller
|
||||
'name' => ['type' => 'string', 'description' => 'The application name.'],
|
||||
'description' => ['type' => 'string', 'description' => 'The application description.'],
|
||||
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
|
||||
'noindex_domains' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'The subset of the application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the application domains are ignored.'],
|
||||
'git_commit_sha' => ['type' => 'string', 'description' => 'The git commit SHA.'],
|
||||
'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'],
|
||||
'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'],
|
||||
@@ -2787,7 +2793,7 @@ class ApplicationsController extends Controller
|
||||
$this->authorize('update', $application);
|
||||
|
||||
$server = $application->destination->server;
|
||||
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', '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', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS];
|
||||
$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', 'use_build_secrets', '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', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS];
|
||||
|
||||
$validationRules = [
|
||||
'name' => 'string|max:255',
|
||||
@@ -3106,8 +3112,14 @@ class ApplicationsController extends Controller
|
||||
if ($dockerComposeDomainsJson->count() > 0) {
|
||||
data_set($data, 'docker_compose_domains', json_encode($dockerComposeDomainsJson));
|
||||
}
|
||||
$requestHasNoindexDomains = $request->has('noindex_domains');
|
||||
data_forget($data, 'noindex_domains');
|
||||
$application->fill($data);
|
||||
if ($application->settings->is_container_label_readonly_enabled && $requestHasDomains && $server->isProxyShouldRun()) {
|
||||
if ($requestHasNoindexDomains) {
|
||||
// Must run after fqdn is filled: flags are kept only for domains the app still has.
|
||||
$application->setNoindexDomains($request->input('noindex_domains') ?? []);
|
||||
}
|
||||
if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains) && $server->isProxyShouldRun()) {
|
||||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
}
|
||||
$application->save();
|
||||
|
||||
@@ -242,6 +242,13 @@ class ServiceApplicationsController extends Controller
|
||||
nullable: true,
|
||||
description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.'
|
||||
),
|
||||
'noindex_domains' => new OA\Property(
|
||||
property: 'noindex_domains',
|
||||
type: 'array',
|
||||
items: new OA\Items(type: 'string'),
|
||||
description: 'The subset of the service application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the domains are ignored.',
|
||||
nullable: true,
|
||||
),
|
||||
'human_name' => new OA\Property(property: 'human_name', type: 'string', nullable: true),
|
||||
'description' => new OA\Property(property: 'description', type: 'string', nullable: true),
|
||||
'image' => new OA\Property(property: 'image', type: 'string', nullable: true),
|
||||
@@ -313,6 +320,7 @@ class ServiceApplicationsController extends Controller
|
||||
|
||||
$allowedFields = [
|
||||
'url',
|
||||
'noindex_domains',
|
||||
'human_name',
|
||||
'description',
|
||||
'image',
|
||||
@@ -324,6 +332,8 @@ class ServiceApplicationsController extends Controller
|
||||
|
||||
$validationRules = [
|
||||
'url' => 'nullable|string',
|
||||
'noindex_domains' => 'sometimes|array|nullable',
|
||||
'noindex_domains.*' => 'string',
|
||||
'human_name' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'image' => 'nullable|string',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
|
||||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Support\ValidationPatterns;
|
||||
@@ -15,6 +16,8 @@ class Domains extends Component
|
||||
use AuthorizesRequests;
|
||||
use InteractsWithCloudflareDomainConnect;
|
||||
|
||||
protected bool $notifyRedirectUpdate = true;
|
||||
|
||||
public Application $application;
|
||||
|
||||
public string $redirect = 'both';
|
||||
@@ -38,6 +41,10 @@ class Domains extends Component
|
||||
|
||||
public string $editingDomain = '';
|
||||
|
||||
public string $editingIndexing = 'index';
|
||||
|
||||
public string $editingDirection = 'both';
|
||||
|
||||
public ?string $editingService = null;
|
||||
|
||||
/** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool}> */
|
||||
@@ -96,6 +103,8 @@ class Domains extends Component
|
||||
return [
|
||||
'newDomain' => ValidationPatterns::applicationDomainRules(),
|
||||
'editingDomain' => ValidationPatterns::applicationDomainRules(),
|
||||
'editingIndexing' => 'string|in:index,noindex',
|
||||
'editingDirection' => 'string|in:both,www,non-www',
|
||||
'redirect' => 'string|required|in:both,www,non-www',
|
||||
'serviceRedirects' => 'array',
|
||||
'serviceRedirects.*' => 'string|in:both,www,non-www',
|
||||
@@ -126,6 +135,22 @@ class Domains extends Component
|
||||
$this->loadDomainState();
|
||||
}
|
||||
|
||||
public function toggleNoindexDomain(string $domain, string|bool $indexing): void
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
$noindex = $indexing === true || $indexing === 'noindex';
|
||||
$domains = $this->application->noindexDomains();
|
||||
$domains = $noindex ? $domains->push($domain) : $domains->reject(fn (string $item) => $item === $domain);
|
||||
|
||||
$this->application->setNoindexDomains($domains);
|
||||
$this->application->save();
|
||||
$this->application->refresh();
|
||||
$this->resetDefaultLabels();
|
||||
$this->dispatch('configurationChanged')->to(ConfigurationChecker::class);
|
||||
$this->dispatch('success', 'Search engine indexing updated.');
|
||||
}
|
||||
|
||||
public function loadDomainState(): void
|
||||
{
|
||||
$this->application->refresh();
|
||||
@@ -887,6 +912,8 @@ class Domains extends Component
|
||||
$this->editingIndex = $index;
|
||||
$this->editingDomain = $this->domainRows[$index]['url'];
|
||||
$this->editingService = $this->domainRows[$index]['service'];
|
||||
$this->editingDirection = $this->serviceRedirectFor($this->editingService);
|
||||
$this->editingIndexing = $this->application->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index';
|
||||
$this->resetEditDomainDnsGate();
|
||||
$this->resetErrorBag('editingDomain');
|
||||
$this->showEditDomainModal = true;
|
||||
@@ -969,6 +996,8 @@ class Domains extends Component
|
||||
$this->editingIndex = null;
|
||||
$this->editingDomain = '';
|
||||
$this->editingService = null;
|
||||
$this->editingDirection = 'both';
|
||||
$this->editingIndexing = 'index';
|
||||
$this->resetEditDomainDnsGate();
|
||||
$this->resetErrorBag('editingDomain');
|
||||
if ($this->pendingAction === 'update') {
|
||||
@@ -1036,6 +1065,21 @@ class Domains extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$noindexDomains = $this->application->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl);
|
||||
if ($this->editingIndexing === 'noindex') {
|
||||
$noindexDomains->push($newUrl);
|
||||
}
|
||||
$this->application->setNoindexDomains($noindexDomains);
|
||||
$this->application->save();
|
||||
$this->resetDefaultLabels();
|
||||
|
||||
if ($this->isCompose && filled($service) && $this->editingDirection !== $this->savedRedirectForService($service)) {
|
||||
$this->serviceRedirects[$this->serviceRedirectWireKey($service)] = $this->editingDirection;
|
||||
$this->notifyRedirectUpdate = false;
|
||||
$this->setServiceRedirect($service);
|
||||
$this->notifyRedirectUpdate = true;
|
||||
}
|
||||
|
||||
$this->forceSaveDomains = false;
|
||||
$this->pendingAction = null;
|
||||
$this->cancelEdit();
|
||||
@@ -1162,13 +1206,16 @@ class Domains extends Component
|
||||
// www / non-www redirects need both hosts configured as real domains so the
|
||||
// proxy can serve the canonical host and redirect the other. Auto-add missing
|
||||
// counterparts instead of leaving them as optional suggestions.
|
||||
$addedDomains = [];
|
||||
if (in_array($this->redirect, ['www', 'non-www'], true)) {
|
||||
$domainsBeforePairing = $this->currentDomainList();
|
||||
if (! $this->ensureWwwNonWwwPairsConfigured(null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->application->refresh();
|
||||
$this->application->redirect = $this->redirect;
|
||||
$addedDomains = $this->currentDomainList()->diff($domainsBeforePairing)->values()->all();
|
||||
}
|
||||
|
||||
$domains = collect($this->application->fqdns);
|
||||
@@ -1183,6 +1230,7 @@ class Domains extends Component
|
||||
$this->resetDefaultLabels();
|
||||
$this->dispatch('success', 'Redirect updated.');
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns($addedDomains);
|
||||
$this->pruneDomainDnsStatusesToCurrentDomains();
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
@@ -1229,12 +1277,15 @@ class Domains extends Component
|
||||
$this->pendingRedirectService = $serviceName;
|
||||
|
||||
// Promote the optional www/non-www suggestion to a real domain for redirects.
|
||||
$addedDomains = [];
|
||||
if (in_array($redirect, ['www', 'non-www'], true)) {
|
||||
$domainsBeforePairing = $this->currentDomainList($serviceName);
|
||||
if (! $this->ensureWwwNonWwwPairsConfigured($serviceName)) {
|
||||
return;
|
||||
}
|
||||
// Ensure we re-read domains after pair save before writing redirect.
|
||||
$this->application->refresh();
|
||||
$addedDomains = $this->currentDomainList($serviceName)->diff($domainsBeforePairing)->values()->all();
|
||||
}
|
||||
|
||||
$allDomains = $this->application->docker_compose_domains
|
||||
@@ -1266,8 +1317,11 @@ class Domains extends Component
|
||||
$this->pendingRedirectService = null;
|
||||
$this->forceSaveDomains = false;
|
||||
$this->resetDefaultLabels();
|
||||
$this->dispatch('success', "Redirect updated for {$serviceName}.");
|
||||
if ($this->notifyRedirectUpdate) {
|
||||
$this->dispatch('success', "Redirect updated for {$serviceName}.");
|
||||
}
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns($addedDomains, $serviceName);
|
||||
$this->pruneDomainDnsStatusesToCurrentDomains();
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Livewire\Project\Service;
|
||||
|
||||
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
|
||||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
@@ -17,6 +18,8 @@ class Domains extends Component
|
||||
use AuthorizesRequests;
|
||||
use InteractsWithCloudflareDomainConnect;
|
||||
|
||||
protected bool $notifyRedirectUpdate = true;
|
||||
|
||||
public Service $service;
|
||||
|
||||
/** @var array<int, array{id: int, name: string, image: ?string, required_port: ?int}> */
|
||||
@@ -43,6 +46,10 @@ class Domains extends Component
|
||||
|
||||
public string $editingDomain = '';
|
||||
|
||||
public string $editingDirection = 'both';
|
||||
|
||||
public string $editingIndexing = 'index';
|
||||
|
||||
public ?int $editingServiceApplicationId = null;
|
||||
|
||||
public bool $showEditDomainModal = false;
|
||||
@@ -95,6 +102,8 @@ class Domains extends Component
|
||||
return [
|
||||
'newDomain' => ValidationPatterns::applicationDomainRules(),
|
||||
'editingDomain' => ValidationPatterns::applicationDomainRules(),
|
||||
'editingDirection' => 'string|in:both,www,non-www',
|
||||
'editingIndexing' => 'string|in:index,noindex',
|
||||
'newServiceApplicationId' => 'nullable|integer',
|
||||
'serviceRedirects' => 'array',
|
||||
'serviceRedirects.*' => 'string|in:both,www,non-www',
|
||||
@@ -114,6 +123,22 @@ class Domains extends Component
|
||||
$this->loadDomainState();
|
||||
}
|
||||
|
||||
public function toggleNoindexDomain(int $serviceApplicationId, string $domain, string|bool $indexing): void
|
||||
{
|
||||
$application = $this->service->applications()->findOrFail($serviceApplicationId);
|
||||
$this->authorize('update', $application);
|
||||
|
||||
$noindex = $indexing === true || $indexing === 'noindex';
|
||||
$domains = $application->noindexDomains();
|
||||
$domains = $noindex ? $domains->push($domain) : $domains->reject(fn (string $item) => $item === $domain);
|
||||
|
||||
$application->setNoindexDomains($domains);
|
||||
$application->save();
|
||||
$this->service->parse();
|
||||
$this->dispatch('configurationChanged')->to(ConfigurationChecker::class);
|
||||
$this->dispatch('success', 'Search engine indexing updated.');
|
||||
}
|
||||
|
||||
public function loadDomainState(): void
|
||||
{
|
||||
$this->service->loadMissing(['applications', 'server']);
|
||||
@@ -614,13 +639,16 @@ class Domains extends Component
|
||||
$this->serviceRedirects[$serviceApplicationId] = $redirect;
|
||||
$this->pendingRedirectServiceApplicationId = $serviceApplicationId;
|
||||
|
||||
$saved = DB::transaction(function () use ($app, $redirect): bool {
|
||||
$addedDomains = [];
|
||||
$saved = DB::transaction(function () use ($app, $redirect, &$addedDomains): bool {
|
||||
// Promote the optional www/non-www suggestion to a real domain for redirects.
|
||||
if (in_array($redirect, ['www', 'non-www'], true)) {
|
||||
$domainsBeforePairing = collect($this->splitDomains($app->fqdn));
|
||||
if (! $this->ensureWwwNonWwwPairsConfigured($app)) {
|
||||
return false;
|
||||
}
|
||||
$app->refresh();
|
||||
$addedDomains = collect($this->splitDomains($app->fqdn))->diff($domainsBeforePairing)->values()->all();
|
||||
}
|
||||
|
||||
$domains = collect($this->splitDomains($app->fqdn));
|
||||
@@ -644,10 +672,13 @@ class Domains extends Component
|
||||
$this->pendingRedirectServiceApplicationId = null;
|
||||
$this->forceSaveDomains = false;
|
||||
$this->forceRemovePort = false;
|
||||
$this->dispatch('success', 'Redirect updated.');
|
||||
if ($this->notifyRedirectUpdate) {
|
||||
$this->dispatch('success', 'Redirect updated.');
|
||||
}
|
||||
$this->dispatch('configurationChanged');
|
||||
$this->pruneDomainDnsStatusesToCurrentDomains();
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns($addedDomains, $serviceApplicationId);
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
@@ -893,6 +924,9 @@ class Domains extends Component
|
||||
$this->editingIndex = $index;
|
||||
$this->editingDomain = $this->domainRows[$index]['url'];
|
||||
$this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id'];
|
||||
$app = $this->findServiceApp($this->editingServiceApplicationId);
|
||||
$this->editingDirection = $this->normalizeRedirect($app?->redirect);
|
||||
$this->editingIndexing = $app?->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index';
|
||||
$this->editDomainDnsFailed = false;
|
||||
$this->editDomainDnsMessage = '';
|
||||
$this->forceSaveEditDns = false;
|
||||
@@ -906,6 +940,8 @@ class Domains extends Component
|
||||
$this->editingIndex = null;
|
||||
$this->editingDomain = '';
|
||||
$this->editingServiceApplicationId = null;
|
||||
$this->editingDirection = 'both';
|
||||
$this->editingIndexing = 'index';
|
||||
$this->editDomainDnsFailed = false;
|
||||
$this->editDomainDnsMessage = '';
|
||||
$this->forceSaveEditDns = false;
|
||||
@@ -963,6 +999,19 @@ class Domains extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$noindexDomains = $app->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl);
|
||||
if ($this->editingIndexing === 'noindex') {
|
||||
$noindexDomains->push($newUrl);
|
||||
}
|
||||
$app->setNoindexDomains($noindexDomains);
|
||||
$app->save();
|
||||
|
||||
if ($this->editingDirection !== $this->normalizeRedirect($app->redirect)) {
|
||||
$this->notifyRedirectUpdate = false;
|
||||
$this->updateServiceRedirect((int) $app->id, $this->editingDirection);
|
||||
$this->notifyRedirectUpdate = true;
|
||||
}
|
||||
|
||||
$this->cancelEdit();
|
||||
$this->dispatch('edit-domain-saved');
|
||||
$this->forceSaveDomains = false;
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Services\DeploymentConfiguration\ConfigurationDiffer;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasConfiguration;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasNoindexDomains;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -34,6 +35,7 @@ use Symfony\Component\Yaml\Yaml;
|
||||
'uuid' => ['type' => 'string', 'description' => 'The application UUID.'],
|
||||
'name' => ['type' => 'string', 'description' => 'The application name.'],
|
||||
'fqdn' => ['type' => 'string', 'nullable' => true, 'description' => 'The application domains.'],
|
||||
'noindex_domains' => ['type' => 'array', 'items' => ['type' => 'string'], 'nullable' => true, 'description' => 'The subset of the application domains served with an X-Robots-Tag: noindex, nofollow response header.'],
|
||||
'config_hash' => ['type' => 'string', 'description' => 'Configuration hash.'],
|
||||
'git_repository' => ['type' => 'string', 'description' => 'Git repository URL.'],
|
||||
'git_branch' => ['type' => 'string', 'description' => 'Git branch.'],
|
||||
@@ -118,7 +120,7 @@ use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class Application extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
private static $parserVersion = '5';
|
||||
|
||||
@@ -126,6 +128,7 @@ class Application extends BaseModel
|
||||
'name',
|
||||
'description',
|
||||
'fqdn',
|
||||
'noindex_domains',
|
||||
'git_repository',
|
||||
'git_branch',
|
||||
'git_commit_sha',
|
||||
@@ -245,6 +248,7 @@ class Application extends BaseModel
|
||||
'manual_webhook_secret_gitlab' => 'encrypted',
|
||||
'manual_webhook_secret_bitbucket' => 'encrypted',
|
||||
'manual_webhook_secret_gitea' => 'encrypted',
|
||||
'noindex_domains' => 'array',
|
||||
'domain_dns_statuses' => 'array',
|
||||
'restart_count' => 'integer',
|
||||
'max_restart_count' => 'integer',
|
||||
@@ -273,6 +277,7 @@ class Application extends BaseModel
|
||||
$application->fqdn = null;
|
||||
}
|
||||
$payload['fqdn'] = $application->fqdn;
|
||||
$application->syncNoindexDomains();
|
||||
}
|
||||
if ($application->isDirty('install_command')) {
|
||||
$payload['install_command'] = str($application->install_command)->trim();
|
||||
@@ -1378,7 +1383,7 @@ class Application extends BaseModel
|
||||
|
||||
private function legacyConfigurationHash(): string
|
||||
{
|
||||
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build);
|
||||
$newConfigHash = base64_encode($this->fqdn.json_encode($this->noindexDomains()->all()).$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build);
|
||||
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->makeVisible('value')->sort());
|
||||
} else {
|
||||
|
||||
@@ -94,6 +94,7 @@ class Service extends BaseModel
|
||||
{
|
||||
$domains = $this->applications()->get()->pluck('fqdn')->sort()->toArray();
|
||||
$domains = implode(',', $domains);
|
||||
$noindexDomains = $this->applications()->get()->pluck('noindex_domains')->flatten()->filter()->sort()->implode(',');
|
||||
|
||||
$applicationImages = $this->applications()->get()->pluck('image')->sort();
|
||||
$databaseImages = $this->databases()->get()->pluck('image')->sort();
|
||||
@@ -104,7 +105,7 @@ class Service extends BaseModel
|
||||
$databaseStorages = $this->databases()->get()->pluck('persistentStorages')->flatten()->sortBy('id');
|
||||
$storages = $applicationStorages->merge($databaseStorages)->implode('updated_at');
|
||||
|
||||
$newConfigHash = $images.$domains.$images.$storages;
|
||||
$newConfigHash = $images.$domains.$images.$storages.$noindexDomains;
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort());
|
||||
$newConfigHash = md5($newConfigHash);
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasNoindexDomains;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
@@ -9,7 +10,7 @@ use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class ServiceApplication extends BaseModel
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use HasFactory, HasNoindexDomains, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'service_id',
|
||||
@@ -17,6 +18,7 @@ class ServiceApplication extends BaseModel
|
||||
'human_name',
|
||||
'description',
|
||||
'fqdn',
|
||||
'noindex_domains',
|
||||
'redirect',
|
||||
'domain_dns_statuses',
|
||||
'ports',
|
||||
@@ -46,6 +48,7 @@ class ServiceApplication extends BaseModel
|
||||
{
|
||||
return [
|
||||
'domain_dns_statuses' => 'array',
|
||||
'noindex_domains' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -60,6 +63,9 @@ class ServiceApplication extends BaseModel
|
||||
if ($service->isDirty('status')) {
|
||||
$service->last_online_at = now();
|
||||
}
|
||||
if ($service->isDirty('fqdn')) {
|
||||
$service->syncNoindexDomains();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@ class ApplicationConfigurationSnapshot
|
||||
{
|
||||
return [
|
||||
$this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'),
|
||||
$this->item('noindex_domains', 'Search engine indexing', $this->application->noindexDomains()->all(), 'redeploy'),
|
||||
$this->item('docker_compose_domains', 'Service domains', $this->decodedComposeDomains(), 'redeploy', displayValue: $this->summarizeText($this->composeDomainsText()), displayFull: $this->composeDomainsText(), diffMode: 'lines'),
|
||||
$this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'),
|
||||
$this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->decodeCustomLabels($this->application->custom_labels)), displayFull: $this->decodeCustomLabels($this->application->custom_labels), diffMode: 'lines'),
|
||||
|
||||
@@ -22,7 +22,7 @@ class ConfigurationDiffer
|
||||
* stored. Older snapshots omitted these keys, which should not make an
|
||||
* unchanged default look like a pending configuration change.
|
||||
*
|
||||
* @var array<string, bool|array<int, bool>>
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private const INTRODUCED_DEFAULTS = [
|
||||
'build.is_static' => false,
|
||||
@@ -37,6 +37,7 @@ class ConfigurationDiffer
|
||||
'runtime.is_log_drain_enabled' => false,
|
||||
'runtime.is_swarm_only_worker_nodes' => true,
|
||||
'runtime.is_preserve_repository_enabled' => false,
|
||||
'domains.noindex_domains' => [],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -61,7 +62,7 @@ class ConfigurationDiffer
|
||||
if (
|
||||
$previous === null
|
||||
&& array_key_exists($key, self::INTRODUCED_DEFAULTS)
|
||||
&& in_array((bool) data_get($current, 'compare_value'), (array) self::INTRODUCED_DEFAULTS[$key], true)
|
||||
&& $this->matchesIntroducedDefault($key, data_get($current, 'compare_value'))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -126,6 +127,17 @@ class ConfigurationDiffer
|
||||
return ConfigurationDiff::fromChanges($changes);
|
||||
}
|
||||
|
||||
private function matchesIntroducedDefault(string $key, mixed $value): bool
|
||||
{
|
||||
$default = self::INTRODUCED_DEFAULTS[$key];
|
||||
|
||||
if (is_array($default) && $default !== [] && array_is_list($default)) {
|
||||
return in_array($value, $default, true);
|
||||
}
|
||||
|
||||
return $value === $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce two multi-line values to only the lines that differ, so the modal
|
||||
* shows just the changed container labels instead of the whole block.
|
||||
|
||||
@@ -598,7 +598,7 @@ class ValidationPatterns
|
||||
* Normalize URL components that are case-insensitive while preserving
|
||||
* case-sensitive path, query, and fragment components.
|
||||
*/
|
||||
private static function normalizeApplicationDomainUrl(string $url): string
|
||||
public static function normalizeApplicationDomainUrl(string $url): string
|
||||
{
|
||||
$components = parse_url($url);
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,8 @@ function sharedDataApplications()
|
||||
'docker_images_to_keep' => 'integer|min:0|max:100',
|
||||
'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\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Url\Url;
|
||||
@@ -418,7 +419,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) {
|
||||
@@ -450,7 +465,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) {
|
||||
@@ -476,7 +498,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');
|
||||
@@ -551,6 +573,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 = [
|
||||
@@ -595,6 +623,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);
|
||||
});
|
||||
@@ -621,6 +652,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);
|
||||
});
|
||||
@@ -639,8 +673,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
|
||||
@@ -673,6 +714,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);
|
||||
});
|
||||
@@ -699,6 +743,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);
|
||||
});
|
||||
@@ -731,6 +778,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()) {
|
||||
@@ -746,6 +794,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:
|
||||
@@ -761,6 +810,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;
|
||||
}
|
||||
@@ -776,6 +826,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,
|
||||
@@ -789,6 +840,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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -798,6 +850,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()) {
|
||||
@@ -812,6 +866,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:
|
||||
@@ -826,6 +881,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;
|
||||
}
|
||||
@@ -840,6 +896,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,
|
||||
@@ -852,6 +909,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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1340,6 +1340,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
if ($isPullRequest) {
|
||||
$labelNetwork = "{$resource->destination->network}-{$pullRequestId}";
|
||||
}
|
||||
$noindexDomains = $isPullRequest ? $fqdns : $originalResource->noindexDomains();
|
||||
$domainServiceName = findComposeServiceName((string) $serviceName, $domains->keys());
|
||||
$composeRedirect = data_get($domains->get($domainServiceName), 'redirect');
|
||||
$redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true)
|
||||
@@ -1357,6 +1358,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: $image,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
));
|
||||
break;
|
||||
@@ -1372,6 +1374,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
service_name: $serviceName,
|
||||
image: $image,
|
||||
predefinedPort: $predefinedPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
));
|
||||
break;
|
||||
@@ -1386,6 +1389,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: $image,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
));
|
||||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
||||
@@ -1399,6 +1403,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
service_name: $serviceName,
|
||||
image: $image,
|
||||
predefinedPort: $predefinedPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
));
|
||||
}
|
||||
@@ -2545,6 +2550,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,
|
||||
@@ -2623,6 +2632,7 @@ function serviceParser(Service $resource): Collection
|
||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: $image,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
));
|
||||
break;
|
||||
@@ -2638,6 +2648,7 @@ function serviceParser(Service $resource): Collection
|
||||
service_name: $serviceName,
|
||||
image: $image,
|
||||
predefinedPort: $predefinedPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
));
|
||||
break;
|
||||
@@ -2652,6 +2663,7 @@ function serviceParser(Service $resource): Collection
|
||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: $image,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
));
|
||||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
||||
@@ -2665,6 +2677,7 @@ function serviceParser(Service $resource): Collection
|
||||
service_name: $serviceName,
|
||||
image: $image,
|
||||
predefinedPort: $predefinedPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
));
|
||||
}
|
||||
|
||||
@@ -3023,6 +3023,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,
|
||||
@@ -3053,6 +3056,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: data_get($service, 'image'),
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
));
|
||||
break;
|
||||
@@ -3067,6 +3071,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: data_get($service, 'image'),
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
));
|
||||
break;
|
||||
@@ -3081,6 +3086,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: data_get($service, 'image'),
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
));
|
||||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
||||
@@ -3093,6 +3099,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: data_get($service, 'image'),
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
));
|
||||
}
|
||||
@@ -3819,6 +3826,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||
});
|
||||
}
|
||||
}
|
||||
$noindexDomains = $pull_request_id !== 0 ? $fqdns : $resource->noindexDomains();
|
||||
$shouldGenerateLabelsExactly = $server->settings->generate_exact_labels;
|
||||
$composeRedirect = data_get($domains, "$serviceName.redirect");
|
||||
$redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true)
|
||||
@@ -3837,6 +3845,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,
|
||||
redirect_direction: $redirectDirection,
|
||||
)
|
||||
);
|
||||
@@ -3852,6 +3861,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,
|
||||
redirect_direction: $redirectDirection,
|
||||
)
|
||||
);
|
||||
@@ -3868,6 +3878,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,
|
||||
redirect_direction: $redirectDirection,
|
||||
)
|
||||
);
|
||||
@@ -3881,6 +3892,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,
|
||||
redirect_direction: $redirectDirection,
|
||||
)
|
||||
);
|
||||
|
||||
+30
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -137,6 +137,13 @@
|
||||
"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."
|
||||
@@ -716,6 +723,13 @@
|
||||
"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."
|
||||
@@ -1295,6 +1309,13 @@
|
||||
"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."
|
||||
@@ -1859,6 +1880,13 @@
|
||||
"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."
|
||||
@@ -2325,6 +2353,13 @@
|
||||
"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."
|
||||
@@ -2930,6 +2965,13 @@
|
||||
"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."
|
||||
@@ -16723,6 +16765,16 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"noindex_domains": {
|
||||
"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.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"human_name": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -21543,6 +21595,14 @@
|
||||
"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."
|
||||
|
||||
@@ -97,6 +97,10 @@ paths:
|
||||
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.'
|
||||
@@ -475,6 +479,10 @@ paths:
|
||||
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.'
|
||||
@@ -853,6 +861,10 @@ paths:
|
||||
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.'
|
||||
@@ -1222,6 +1234,10 @@ paths:
|
||||
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.'
|
||||
@@ -1536,6 +1552,10 @@ paths:
|
||||
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.'
|
||||
@@ -1941,6 +1961,10 @@ paths:
|
||||
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.'
|
||||
@@ -10616,6 +10640,10 @@ paths:
|
||||
url:
|
||||
description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.'
|
||||
type: [string, 'null']
|
||||
noindex_domains:
|
||||
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.'
|
||||
type: [array, 'null']
|
||||
items: { type: string }
|
||||
human_name:
|
||||
type: [string, 'null']
|
||||
description:
|
||||
@@ -13721,6 +13749,12 @@ components:
|
||||
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.'
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
'emptyText' => 'No options available.',
|
||||
'live' => false,
|
||||
'onChange' => null, // optional $wire method to call after a selection
|
||||
'onChangeArgs' => null, // optional arguments followed by the selected value
|
||||
'wire' => true, // false = purely client-side value (no Livewire binding)
|
||||
'value' => null, // initial value when wire=false
|
||||
'disabled' => false,
|
||||
@@ -55,7 +56,12 @@
|
||||
this.open = false;
|
||||
if (String(option.value) === String(this.value)) return;
|
||||
this.value = option.value;
|
||||
@if ($onChange) this.$nextTick(() => this.$wire.{{ $onChange }}()); @endif
|
||||
this.$dispatch('listbox-change', { value: option.value });
|
||||
@if ($onChange && is_array($onChangeArgs))
|
||||
this.$nextTick(() => this.$wire.{{ $onChange }}(...@js($onChangeArgs), option.value));
|
||||
@elseif ($onChange)
|
||||
this.$nextTick(() => this.$wire.{{ $onChange }}());
|
||||
@endif
|
||||
},
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
@@ -102,8 +108,15 @@
|
||||
</button>
|
||||
@if ($portal)
|
||||
<template x-teleport="body">
|
||||
<div id="{{ $panelId }}" class="listbox-panel" style="position: fixed; z-index: 9999" x-show="open"
|
||||
<div id="{{ $panelId }}" class="listbox-panel"
|
||||
style="position: fixed; z-index: 9999; visibility: hidden" x-show="open && positioned"
|
||||
x-cloak :style="{ visibility: positioned ? 'visible' : 'hidden' }"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-1 scale-[0.98]"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]"
|
||||
x-effect="if (open) requestAnimationFrame(() => positionPanel($el))" role="listbox">
|
||||
<div x-show="options.length === 0"
|
||||
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
@@ -124,7 +137,13 @@
|
||||
</div>
|
||||
</template>
|
||||
@else
|
||||
<div x-ref="panel" class="listbox-panel" x-show="open" x-cloak role="listbox">
|
||||
<div x-ref="panel" class="listbox-panel" x-show="open" x-cloak
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-1 scale-[0.98]"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]" role="listbox">
|
||||
<div x-show="options.length === 0"
|
||||
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $emptyText }}
|
||||
|
||||
@@ -15,15 +15,18 @@
|
||||
domainSearch: '',
|
||||
modalOpen: @js($showEditDomainModal || $editDomainDnsFailed),
|
||||
editingServiceLabel: @js($editingService ?? ''),
|
||||
// Local-only until Save — never touch $wire on open/close (avoids Livewire toJSON proxy bugs).
|
||||
localEditingIndex: @js($editingIndex),
|
||||
localEditingDomain: @js($editingDomain),
|
||||
localEditingService: @js($editingService),
|
||||
openEditDomain(index, url, service) {
|
||||
localDirection: 'both',
|
||||
localIndexing: 'index',
|
||||
openEditDomain(index, url, service, indexing, direction) {
|
||||
this.localEditingIndex = index;
|
||||
this.localEditingDomain = url;
|
||||
this.localEditingService = service;
|
||||
this.editingServiceLabel = service || '';
|
||||
this.localDirection = direction || 'both';
|
||||
this.localIndexing = indexing || 'index';
|
||||
this.modalOpen = true;
|
||||
this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.());
|
||||
},
|
||||
@@ -39,6 +42,8 @@
|
||||
$wire.editingIndex = this.localEditingIndex;
|
||||
$wire.editingDomain = this.localEditingDomain;
|
||||
$wire.editingService = this.localEditingService;
|
||||
$wire.editingDirection = this.localDirection;
|
||||
$wire.editingIndexing = this.localIndexing;
|
||||
$wire.showEditDomainModal = true;
|
||||
},
|
||||
matchesDomainSearch(value) {
|
||||
@@ -48,7 +53,7 @@
|
||||
return values.some((value) => this.matchesDomainSearch(value));
|
||||
},
|
||||
}"
|
||||
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.service)"
|
||||
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.service, $event.detail.indexing, $event.detail.direction)"
|
||||
@edit-domain-saved.window="closeEditDomain()">
|
||||
<x-application.settings-section id="domains-section" title="Domains" :helper="$helperText">
|
||||
@can('update', $application)
|
||||
@@ -81,16 +86,11 @@
|
||||
|
||||
@if (! $isCompose)
|
||||
@if ($labelsAreWritable)
|
||||
@if ($application->redirect === 'both')
|
||||
<x-forms.input label="Direction" value="Allow www & non-www" readonly
|
||||
helper="Readonly labels are disabled. You can set the direction in the labels section." />
|
||||
@elseif ($application->redirect === 'www')
|
||||
<x-forms.input label="Direction" value="Redirect to www" readonly
|
||||
helper="Readonly labels are disabled. You can set the direction in the labels section." />
|
||||
@elseif ($application->redirect === 'non-www')
|
||||
<x-forms.input label="Direction" value="Redirect to non-www" readonly
|
||||
helper="Readonly labels are disabled. You can set the direction in the labels section." />
|
||||
@endif
|
||||
<x-forms.input label="Direction" value="{{ match ($application->redirect) {
|
||||
'www' => 'Redirect to www',
|
||||
'non-www' => 'Redirect to non-www',
|
||||
default => 'Allow www & non-www',
|
||||
} }}" readonly helper="Readonly labels are disabled. You can set the direction in the labels section." />
|
||||
@else
|
||||
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -113,11 +113,8 @@
|
||||
@endcan
|
||||
</div>
|
||||
@endif
|
||||
@elseif (! $labelsAreWritable && count($composeServices) > 0 && $composeDomainGroups->isNotEmpty())
|
||||
<p class="text-sm text-neutral-500 dark:text-fg-dim">
|
||||
Per-service www/non-www redirects are available next to each service group in the table below.
|
||||
</p>
|
||||
@endif
|
||||
|
||||
</x-application.settings-section>
|
||||
|
||||
{{-- Toolbar --}}
|
||||
@@ -140,7 +137,9 @@
|
||||
</div>
|
||||
@endif
|
||||
@can('update', $application)
|
||||
@include('livewire.project.shared.cloudflare-autoconfigure')
|
||||
<div class="relative shrink-0">
|
||||
@include('livewire.project.shared.cloudflare-autoconfigure')
|
||||
</div>
|
||||
@unless ($labelsAreWritable)
|
||||
@if (! $isCompose || count($composeServices) > 0)
|
||||
<x-modal-input title="Add domain" :closeOutside="false" :wireIgnore="false"
|
||||
@@ -231,57 +230,30 @@
|
||||
->map(fn ($serviceName) => $serviceName.' '.$grouped->get($serviceName, collect())->pluck('url')->implode(' '))
|
||||
->values();
|
||||
@endphp
|
||||
<div class="overflow-hidden">
|
||||
<div>
|
||||
@foreach ($serviceOrder as $serviceName)
|
||||
@php
|
||||
$rows = $grouped->get($serviceName, collect());
|
||||
$redirectWireKey = $this->serviceRedirectWireKey($serviceName);
|
||||
$redirect = $serviceRedirects[$redirectWireKey] ?? 'both';
|
||||
$redirectLabel = match ($redirect) {
|
||||
'www' => 'Redirect to www',
|
||||
'non-www' => 'Redirect to non-www',
|
||||
default => 'Allow both',
|
||||
};
|
||||
@endphp
|
||||
<section id="application-compose-domain-group-{{ $redirectWireKey }}"
|
||||
wire:key="application-compose-domain-group-{{ $redirectWireKey }}"
|
||||
x-show="matchesDomainSearch(@js($serviceName.' '.$rows->pluck('url')->implode(' ')))"
|
||||
class="border-b border-neutral-200 last:border-b-0 dark:border-white/10">
|
||||
<div class="flex w-full items-center gap-3 px-4 py-3">
|
||||
<div class="flex w-full items-center gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-black dark:text-white">
|
||||
{{ $serviceName }}
|
||||
</span>
|
||||
@unless ($labelsAreWritable)
|
||||
@can('update', $application)
|
||||
<div class="relative flex shrink-0 items-center gap-2 px-1 py-1 text-sm text-neutral-600 dark:text-fg-dim"
|
||||
wire:loading.class="opacity-50"
|
||||
wire:target="serviceRedirects.{{ $redirectWireKey }}">
|
||||
<span>{{ $redirectLabel }}</span>
|
||||
<x-reicon name="chevron-down" class="size-4 shrink-0"
|
||||
wire:loading.remove
|
||||
wire:target="serviceRedirects.{{ $redirectWireKey }}" />
|
||||
<x-loading-on-button wire:loading.delay
|
||||
wire:target="serviceRedirects.{{ $redirectWireKey }}" />
|
||||
<select id="application-compose-domain-redirect-{{ $redirectWireKey }}"
|
||||
wire:model.change="serviceRedirects.{{ $redirectWireKey }}"
|
||||
wire:change="setServiceRedirect(@js($serviceName))"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="serviceRedirects.{{ $redirectWireKey }}"
|
||||
class="absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-wait"
|
||||
aria-label="Redirect direction for {{ $serviceName }}">
|
||||
<option value="both">Allow www & non-www</option>
|
||||
<option value="www">Redirect to www</option>
|
||||
<option value="non-www">Redirect to non-www</option>
|
||||
</select>
|
||||
</div>
|
||||
@else
|
||||
<span class="shrink-0 text-sm text-neutral-600 dark:text-fg-dim">{{ $redirectLabel }}</span>
|
||||
@endcan
|
||||
@endunless
|
||||
</div>
|
||||
|
||||
<div wire:key="application-compose-domain-rows-{{ $redirectWireKey }}-{{ md5(serialize($rows->all())) }}"
|
||||
class="data-table w-full">
|
||||
<div class="data-table-header domains-table-grid">
|
||||
<span>Domain</span>
|
||||
<span>DNS</span>
|
||||
<span>Last checked</span>
|
||||
<span></span>
|
||||
</div>
|
||||
@foreach ($rows as $row)
|
||||
@php
|
||||
$index = collect($domainRows)->search(
|
||||
@@ -296,6 +268,7 @@
|
||||
'application' => $application,
|
||||
'labelsAreWritable' => $labelsAreWritable,
|
||||
'isCompose' => false,
|
||||
'domainDirection' => $serviceRedirects[$redirectWireKey] ?? 'both',
|
||||
])
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -384,6 +357,24 @@
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
@unless ($labelsAreWritable)
|
||||
<div class="grid gap-4 {{ $isCompose ? 'sm:grid-cols-2' : '' }}">
|
||||
@if ($isCompose)
|
||||
<x-forms.listbox id="edit-domain-direction" label="Direction"
|
||||
:wire="false" value="both" x-model="localDirection" portal :options="[
|
||||
['value' => 'both', 'label' => 'Allow www & non-www'],
|
||||
['value' => 'www', 'label' => 'Redirect to www'],
|
||||
['value' => 'non-www', 'label' => 'Redirect to non-www'],
|
||||
]" />
|
||||
@endif
|
||||
<x-forms.listbox id="edit-domain-indexing" label="Search engine indexing"
|
||||
:wire="false" value="index" x-model="localIndexing" portal :options="[
|
||||
['value' => 'index', 'label' => 'Indexable'],
|
||||
['value' => 'noindex', 'label' => 'Noindex'],
|
||||
]" />
|
||||
</div>
|
||||
@endunless
|
||||
|
||||
@if ($editDomainDnsFailed)
|
||||
<x-callout type="danger" title="DNS is not pointing to the right IP">
|
||||
This domain does not currently resolve to this server.
|
||||
|
||||
@@ -107,6 +107,8 @@
|
||||
index: {{ $index }},
|
||||
url: @js($row['url']),
|
||||
service: @js($row['service'] ?? null),
|
||||
indexing: @js($application->isDomainNoindexed($row['url']) ? 'noindex' : 'index'),
|
||||
direction: @js($domainDirection ?? $redirect),
|
||||
})"
|
||||
class="icon-button shrink-0"
|
||||
title="Edit domain" aria-label="Edit domain">
|
||||
|
||||
@@ -22,11 +22,15 @@
|
||||
localEditingIndex: @js($editingIndex),
|
||||
localEditingDomain: @js($editingDomain),
|
||||
localEditingServiceApplicationId: @js($editingServiceApplicationId),
|
||||
openEditDomain(index, url, serviceApplicationId, serviceLabel) {
|
||||
localDirection: 'both',
|
||||
localIndexing: 'index',
|
||||
openEditDomain(index, url, serviceApplicationId, serviceLabel, direction, indexing) {
|
||||
this.localEditingIndex = index;
|
||||
this.localEditingDomain = url;
|
||||
this.localEditingServiceApplicationId = serviceApplicationId;
|
||||
this.editingServiceLabel = serviceLabel || '';
|
||||
this.localDirection = direction || 'both';
|
||||
this.localIndexing = indexing || 'index';
|
||||
this.modalOpen = true;
|
||||
this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.());
|
||||
},
|
||||
@@ -41,6 +45,8 @@
|
||||
$wire.editingIndex = this.localEditingIndex;
|
||||
$wire.editingDomain = this.localEditingDomain;
|
||||
$wire.editingServiceApplicationId = this.localEditingServiceApplicationId;
|
||||
$wire.editingDirection = this.localDirection;
|
||||
$wire.editingIndexing = this.localIndexing;
|
||||
$wire.showEditDomainModal = true;
|
||||
},
|
||||
matchesDomainSearch(value) {
|
||||
@@ -50,7 +56,7 @@
|
||||
return values.some((value) => this.matchesDomainSearch(value));
|
||||
},
|
||||
}"
|
||||
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.serviceApplicationId, $event.detail.serviceLabel)"
|
||||
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.serviceApplicationId, $event.detail.serviceLabel, $event.detail.direction, $event.detail.indexing)"
|
||||
@edit-domain-saved.window="closeEditDomain()">
|
||||
<x-application.settings-section id="service-domains-section" title="Domains">
|
||||
@can('update', $service)
|
||||
@@ -94,7 +100,9 @@
|
||||
@endif
|
||||
@can('update', $service)
|
||||
@if ($serviceAppCount > 0)
|
||||
@include('livewire.project.shared.cloudflare-autoconfigure')
|
||||
<div class="relative shrink-0">
|
||||
@include('livewire.project.shared.cloudflare-autoconfigure')
|
||||
</div>
|
||||
<x-modal-input title="Add domain" :closeOutside="false" :wireIgnore="false"
|
||||
canGate="update" :canResource="$service">
|
||||
<x-slot:content>
|
||||
@@ -177,31 +185,12 @@
|
||||
@php
|
||||
$app = collect($serviceApps)->firstWhere('id', (int) $appId);
|
||||
$heading = \Illuminate\Support\Str::headline($app['name'] ?? $rows->first()['service_name'] ?? 'Service');
|
||||
$redirect = $serviceRedirects[$appId] ?? 'both';
|
||||
$redirectLabel = match ($redirect) {
|
||||
'www' => 'Redirect to www',
|
||||
'non-www' => 'Redirect to non-www',
|
||||
default => 'Allow both',
|
||||
};
|
||||
@endphp
|
||||
<section id="service-domain-group-{{ $appId }}" wire:key="service-domain-group-{{ $appId }}"
|
||||
x-show="matchesDomainSearch(@js($heading.' '.$rows->pluck('url')->implode(' ')))"
|
||||
class="border-b border-neutral-200 last:border-b-0 dark:border-white/10">
|
||||
<div class="flex w-full items-center gap-3 px-4 py-3">
|
||||
<div class="flex w-full items-center gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-black dark:text-white">{{ $heading }}</span>
|
||||
@can('update', $service)
|
||||
<div class="w-52 shrink-0"
|
||||
wire:loading.class="opacity-50" wire:target="serviceRedirects.{{ $appId }}">
|
||||
<x-forms.listbox id="serviceRedirects.{{ $appId }}"
|
||||
htmlId="service-domain-redirect-{{ $appId }}" live :options="[
|
||||
['value' => 'both', 'label' => 'Allow www & non-www'],
|
||||
['value' => 'www', 'label' => 'Redirect to www'],
|
||||
['value' => 'non-www', 'label' => 'Redirect to non-www'],
|
||||
]" />
|
||||
</div>
|
||||
@else
|
||||
<span class="shrink-0 text-sm text-neutral-600 dark:text-fg-dim">{{ $redirectLabel }}</span>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
<div wire:key="service-domain-rows-{{ $appId }}-{{ md5(serialize($rows->all())) }}">
|
||||
@@ -210,7 +199,7 @@
|
||||
'domainRows' => $domainRows,
|
||||
'service' => $service,
|
||||
'showServiceColumn' => false,
|
||||
'showHeader' => false,
|
||||
'showHeader' => true,
|
||||
])
|
||||
</div>
|
||||
</section>
|
||||
@@ -279,6 +268,20 @@
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<x-forms.listbox id="edit-service-domain-direction" label="Direction"
|
||||
:wire="false" value="both" x-model="localDirection" portal :options="[
|
||||
['value' => 'both', 'label' => 'Allow www & non-www'],
|
||||
['value' => 'www', 'label' => 'Redirect to www'],
|
||||
['value' => 'non-www', 'label' => 'Redirect to non-www'],
|
||||
]" />
|
||||
<x-forms.listbox id="edit-service-domain-indexing" label="Search engine indexing"
|
||||
:wire="false" value="index" x-model="localIndexing" portal :options="[
|
||||
['value' => 'index', 'label' => 'Indexable'],
|
||||
['value' => 'noindex', 'label' => 'Noindex'],
|
||||
]" />
|
||||
</div>
|
||||
|
||||
@if ($editDomainDnsFailed)
|
||||
<x-callout type="danger" title="DNS is not pointing to the right IP">
|
||||
This domain does not currently resolve to this server.
|
||||
|
||||
@@ -131,9 +131,11 @@
|
||||
@click="$dispatch('open-edit-domain', {
|
||||
index: {{ $index }},
|
||||
url: @js($row['url']),
|
||||
serviceApplicationId: {{ (int) ($row['service_application_id'] ?? 0) }},
|
||||
serviceLabel: @js($serviceLabel),
|
||||
})"
|
||||
serviceApplicationId: {{ (int) ($row['service_application_id'] ?? 0) }},
|
||||
serviceLabel: @js($serviceLabel),
|
||||
direction: @js($serviceRedirects[$row['service_application_id']] ?? 'both'),
|
||||
indexing: @js($service->applications->firstWhere('id', $row['service_application_id'])?->isDomainNoindexed($row['url']) ? 'noindex' : 'index'),
|
||||
})"
|
||||
class="icon-button shrink-0" title="Edit domain" aria-label="Edit domain">
|
||||
<x-reicon name="settings" class="size-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
x-bind:aria-expanded="dnsEntriesOpen" title="DNS entries for this server">
|
||||
<x-reicon name="globe" class="size-3.5" />
|
||||
DNS entries
|
||||
<span class="inline-flex transition-transform" :class="dnsEntriesOpen && 'rotate-180'">
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" class="size-3.5 shrink-0 opacity-60">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8 9 4-4 4 4m0 6-4 4-4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-show="dnsEntriesOpen" x-cloak role="menu" x-transition.origin.top.right
|
||||
class="listbox-panel left-auto! right-0! z-[90]! w-56! min-w-56!">
|
||||
|
||||
@@ -435,7 +435,7 @@
|
||||
"category": "messaging",
|
||||
"logo": "svgs/buzz.svg",
|
||||
"minversion": "0.0.0",
|
||||
"template_last_updated_at": "2026-07-23T17:25:53+02:00",
|
||||
"template_last_updated_at": null,
|
||||
"port": "3000"
|
||||
},
|
||||
"calibre-web-automated-book-downloader": {
|
||||
@@ -552,7 +552,7 @@
|
||||
"category": "backend",
|
||||
"logo": "svgs/denoKV.svg",
|
||||
"minversion": "0.0.0",
|
||||
"template_last_updated_at": "2026-08-07T18:17:33+02:00",
|
||||
"template_last_updated_at": null,
|
||||
"port": "8080"
|
||||
},
|
||||
"changedetection": {
|
||||
|
||||
@@ -435,7 +435,7 @@
|
||||
"category": "messaging",
|
||||
"logo": "svgs/buzz.svg",
|
||||
"minversion": "0.0.0",
|
||||
"template_last_updated_at": "2026-07-23T17:25:53+02:00",
|
||||
"template_last_updated_at": null,
|
||||
"port": "3000"
|
||||
},
|
||||
"calibre-web-automated-book-downloader": {
|
||||
@@ -552,7 +552,7 @@
|
||||
"category": "backend",
|
||||
"logo": "svgs/denoKV.svg",
|
||||
"minversion": "0.0.0",
|
||||
"template_last_updated_at": "2026-08-07T18:17:33+02:00",
|
||||
"template_last_updated_at": null,
|
||||
"port": "8080"
|
||||
},
|
||||
"changedetection": {
|
||||
|
||||
@@ -58,6 +58,42 @@ it('stores a diff between successful deployments', function () {
|
||||
->and(data_get($secondDeployment->configuration_diff, 'changes.0.label'))->toBe('Build command');
|
||||
});
|
||||
|
||||
it('reports noindex domain changes as requiring a redeploy', function () {
|
||||
$application = configurationChangedTestApplication([
|
||||
'fqdn' => 'https://app.example.com,https://staging.example.com',
|
||||
]);
|
||||
$deployment = configurationChangedDeployment($application);
|
||||
$application->markDeploymentConfigurationApplied($deployment);
|
||||
|
||||
$application->setNoindexDomains(['https://staging.example.com']);
|
||||
$application->save();
|
||||
|
||||
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
|
||||
$change = collect($diff->changes())->firstWhere('key', 'domains.noindex_domains');
|
||||
|
||||
expect($diff->isChanged())->toBeTrue()
|
||||
->and($change)->not->toBeNull()
|
||||
->and($change['label'])->toBe('Search engine indexing')
|
||||
->and($change['impact'])->toBe('redeploy');
|
||||
});
|
||||
|
||||
it('does not flag applications whose older snapshot omitted noindex domains', function () {
|
||||
$application = configurationChangedTestApplication([
|
||||
'fqdn' => 'https://app.example.com',
|
||||
]);
|
||||
$deployment = configurationChangedDeployment($application);
|
||||
$application->markDeploymentConfigurationApplied($deployment);
|
||||
|
||||
$snapshot = $deployment->refresh()->configuration_snapshot;
|
||||
$snapshot['sections']['domains']['items'] = collect($snapshot['sections']['domains']['items'])
|
||||
->reject(fn (array $item): bool => $item['key'] === 'noindex_domains')
|
||||
->values()
|
||||
->all();
|
||||
$deployment->update(['configuration_snapshot' => $snapshot]);
|
||||
|
||||
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
|
||||
});
|
||||
|
||||
it('checks legacy preview deployment configuration hash using preview environment variable query', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
|
||||
|
||||
@@ -271,6 +271,8 @@ it('updates a domain in place via modal', function () {
|
||||
->call('startEdit', 0)
|
||||
->assertSet('showEditDomainModal', true)
|
||||
->assertSet('editingDomain', 'https://old.example.com')
|
||||
->assertSee('Direction')
|
||||
->assertSee('Search engine indexing')
|
||||
->set('editingDomain', 'https://new.example.com')
|
||||
->call('updateDomain')
|
||||
->assertHasNoErrors()
|
||||
@@ -375,7 +377,8 @@ it('auto-adds missing www counterpart as a normal domain when setting www redire
|
||||
->assertSet('domainRows.0.is_suggested', false)
|
||||
->assertSet('domainRows.1.is_suggested', false)
|
||||
->assertSet('domainRows.0.url', 'https://example.com')
|
||||
->assertSet('domainRows.1.url', 'https://www.example.com');
|
||||
->assertSet('domainRows.1.url', 'https://www.example.com')
|
||||
->assertSet('domainRows.1.checked_at', fn (?string $checkedAt): bool => filled($checkedAt));
|
||||
|
||||
$this->application->refresh();
|
||||
|
||||
@@ -1216,17 +1219,46 @@ it('uses the compact service domains layout for compose applications', function
|
||||
expect($view)
|
||||
->toContain('application-compose-domain-group-{{ $redirectWireKey }}')
|
||||
->toContain('class="application-settings-section-body mt-1 scroll-mt-28')
|
||||
->toContain('aria-label="Redirect direction for {{ $serviceName }}"')
|
||||
->toContain('wire:target="serviceRedirects.{{ $redirectWireKey }}"')
|
||||
->not->toContain('wire:target="serviceRedirects.{{ $redirectWireKey }},setServiceRedirect"')
|
||||
->toContain('bg-neutral-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]')
|
||||
->toContain('class="data-table-header domains-table-grid"')
|
||||
->toContain('id="edit-domain-direction"')
|
||||
->not->toContain('htmlId="application-compose-domain-redirect-{{ $redirectWireKey }}"')
|
||||
->not->toContain('aria-label="Redirect direction for {{ $serviceName }}"')
|
||||
->not->toContain('title="No domains for this service"');
|
||||
});
|
||||
|
||||
it('updates a compose service redirect from the edit domain modal', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'fqdn' => null,
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
|
||||
'docker_compose_domains' => json_encode([
|
||||
'web' => ['domain' => 'https://web.example.com', 'redirect' => 'both'],
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->set('isCompose', true)
|
||||
->set('composeServices', ['web'])
|
||||
->call('startEdit', 0)
|
||||
->assertSet('editingDirection', 'both')
|
||||
->set('editingDirection', 'www')
|
||||
->call('updateDomain')
|
||||
->assertDispatched('success');
|
||||
|
||||
$domains = json_decode($this->application->fresh()->docker_compose_domains, true);
|
||||
|
||||
expect(data_get($domains, 'web.redirect'))->toBe('www')
|
||||
->and(data_get($domains, 'web.domain'))->toContain('https://www.web.example.com');
|
||||
});
|
||||
|
||||
it('provides client-side search for compose service domains', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('x-model="domainSearch"')
|
||||
->toContain('class="ml-auto flex flex-wrap items-center gap-2"')
|
||||
->toContain('<div class="relative shrink-0">')
|
||||
->toContain('placeholder="Search services or domains"')
|
||||
->toContain('x-show="matchesDomainSearch(')
|
||||
->toContain('title="No domains found"')
|
||||
@@ -1326,3 +1358,20 @@ it('uses compose service redirect for suggested domain messaging when direction
|
||||
->and($suggested['suggestion_role'] ?? null)->toBe('pair')
|
||||
->and($suggested['url'] ?? null)->toBe('https://www.web.example.com');
|
||||
});
|
||||
|
||||
it('updates search engine indexing from the domains view', function () {
|
||||
$this->application->update(['fqdn' => 'https://app.example.com,https://staging.example.com']);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSee('Noindex')
|
||||
->assertSee('Indexable')
|
||||
->assertSee('x-model="localIndexing"', false)
|
||||
->assertDontSee('@change="$wire.toggleNoindexDomain', false)
|
||||
->assertDontSee('@js(', false)
|
||||
->call('toggleNoindexDomain', 'https://staging.example.com', 'noindex')
|
||||
->assertDispatched('configurationChanged')
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->application->refresh()->noindexDomains()->all())
|
||||
->toBe(['https://staging.example.com']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\Domains;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
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 domains view toggle persists the flag', function () {
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0], []);
|
||||
});
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'fqdn' => 'https://prod.example.com,https://staging.example.com',
|
||||
'static_image' => 'nginx:alpine',
|
||||
'base_directory' => '/',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
'redirect' => 'no',
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->call('toggleNoindexDomain', 'https://staging.example.com', true)
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($application->refresh()->noindexDomains()->all())
|
||||
->toBe(['https://staging.example.com']);
|
||||
});
|
||||
});
|
||||
@@ -159,6 +159,26 @@ it('shows domain changes when the domain page dispatches a configuration change'
|
||||
->assertSee('https://changed.example.com');
|
||||
});
|
||||
|
||||
it('shows noindex changes when the domains page dispatches a configuration change', function () {
|
||||
$application = configurationCheckerApplication($this->environment, [
|
||||
'fqdn' => 'https://example.com,https://staging.example.com',
|
||||
]);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSet('isConfigurationChanged', false);
|
||||
|
||||
$application->setNoindexDomains(['https://staging.example.com']);
|
||||
$application->save();
|
||||
|
||||
$component
|
||||
->dispatch('configurationChanged')
|
||||
->assertSet('isConfigurationChanged', true)
|
||||
->assertSee('The latest configuration has not been applied')
|
||||
->assertSee('Search engine indexing')
|
||||
->assertSee('Redeploy to apply.');
|
||||
});
|
||||
|
||||
it('shows an unapplied configuration warning after a directory mount is added', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Service\Domains;
|
||||
use App\Livewire\Project\Service\EditDomain;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
@@ -74,6 +75,16 @@ it('loads the EditDomain component with required port', function () {
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('marks noindex changes as pending configuration', function () {
|
||||
$this->service->isConfigurationChanged(save: true);
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->call('toggleNoindexDomain', $this->serviceApplication->id, 'http://example.com:8000', true)
|
||||
->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
|
||||
|
||||
@@ -90,7 +90,7 @@ beforeEach(function () {
|
||||
]);
|
||||
});
|
||||
|
||||
it('groups configured domains with their service redirect and excludes services without domains', function () {
|
||||
it('groups configured domains and shows redirect settings in the edit modal', function () {
|
||||
$this->apiApp->update([
|
||||
'fqdn' => 'https://api.example.com,https://admin.example.com',
|
||||
]);
|
||||
@@ -100,18 +100,22 @@ it('groups configured domains with their service redirect and excludes services
|
||||
->assertSee('API')
|
||||
->assertSee('https://api.example.com')
|
||||
->assertSee('https://admin.example.com')
|
||||
->call('startEdit', 0)
|
||||
->html();
|
||||
|
||||
expect($html)
|
||||
->toContain("service-domain-group-{$this->apiApp->id}")
|
||||
->toContain("id=\"service-domain-redirect-{$this->apiApp->id}-trigger\"")
|
||||
->toContain("id=\"edit-service-domain-redirect-{$this->apiApp->id}-trigger\"")
|
||||
->toContain("serviceRedirects.{$this->apiApp->id}")
|
||||
->toContain('class="listbox-trigger"')
|
||||
->toContain('application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-visible')
|
||||
->not->toContain("<select id=\"service-domain-redirect-{$this->apiApp->id}\"")
|
||||
->not->toContain("service-domain-redirect-toggle-{$this->apiApp->id}")
|
||||
->toContain('dark:bg-white/[0.04]')
|
||||
->toContain('<span>Domain</span>')
|
||||
->toContain('<span>DNS</span>')
|
||||
->toContain('<span>Last checked</span>')
|
||||
->not->toContain("service-domain-group-{$this->webApp->id}")
|
||||
->and(substr_count($html, '2 domains'))->toBe(1)
|
||||
->and(strpos($html, '>API</span>'))->toBeLessThan(strpos($html, '<span>Domain</span>'))
|
||||
->and(substr_count($html, "id=\"service-domain-group-{$this->apiApp->id}\""))->toBe(1);
|
||||
});
|
||||
|
||||
@@ -194,6 +198,7 @@ it('saves the explicitly selected service redirect value', function () {
|
||||
->call('updateServiceRedirect', $this->webApp->id, 'www')
|
||||
->assertDispatched('success')
|
||||
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->contains('https://www.web.example.com'))
|
||||
->assertSet('domainRows', fn (array $rows): bool => filled(collect($rows)->firstWhere('url', 'https://www.web.example.com')['checked_at'] ?? null))
|
||||
->assertSee('https://www.web.example.com');
|
||||
|
||||
expect($this->webApp->fresh()->redirect)->toBe('www');
|
||||
@@ -270,6 +275,8 @@ it('provides client-side search for service domains', function () {
|
||||
|
||||
expect($view)
|
||||
->toContain('x-model="domainSearch"')
|
||||
->toContain('class="ml-auto flex flex-wrap items-center gap-2"')
|
||||
->toContain('<div class="relative shrink-0">')
|
||||
->toContain('placeholder="Search services or domains"')
|
||||
->toContain('x-show="matchesDomainSearch(')
|
||||
->toContain('title="No domains found"')
|
||||
@@ -343,6 +350,8 @@ it('prunes the previous dns status when a service domain is renamed', function (
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->call('startEdit', 0)
|
||||
->assertSee('Direction')
|
||||
->assertSee('Search engine indexing')
|
||||
->set('editingDomain', 'https://renamed.example.com')
|
||||
->call('updateDomain')
|
||||
->assertHasNoErrors()
|
||||
@@ -357,6 +366,15 @@ it('prunes the previous dns status when a service domain is renamed', function (
|
||||
->and($this->apiApp->domain_dns_statuses['https://renamed.example.com']['status'])->toBe('skipped');
|
||||
});
|
||||
|
||||
it('only shows the domain notification when redirect changes through edit domain', function () {
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->call('startEdit', 0)
|
||||
->set('editingDirection', 'www')
|
||||
->call('updateDomain')
|
||||
->assertDispatched('success', 'Domain updated.')
|
||||
->assertNotDispatched('success', 'Redirect updated.');
|
||||
});
|
||||
|
||||
it('does not restore stale dns status when a removed service domain is re-added', function () {
|
||||
$this->apiApp->update([
|
||||
'domain_dns_statuses' => [
|
||||
@@ -509,3 +527,22 @@ it('exposes the stack domains route', function () {
|
||||
->assertSeeLivewire(Domains::class)
|
||||
->assertSee('Domains');
|
||||
});
|
||||
|
||||
it('updates search engine indexing from the service domains view', function () {
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->assertSee('Noindex')
|
||||
->assertSee('Indexable')
|
||||
->assertSee('x-model="localIndexing"', false)
|
||||
->assertDontSee('@change="$wire.toggleNoindexDomain', false)
|
||||
->assertDontSee('@change="$wire.updateServiceRedirect', false)
|
||||
->assertDontSee('@js(', false)
|
||||
->call('toggleNoindexDomain', $this->apiApp->id, 'https://api.example.com', 'noindex')
|
||||
->assertDispatched('configurationChanged')
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->apiApp->refresh()->noindexDomains()->all())
|
||||
->toBe(['https://api.example.com']);
|
||||
|
||||
expect(file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php')))
|
||||
->not->toContain('<select');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user