diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 5ad3aa09df..446ef983f5 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -362,6 +362,7 @@ class ApplicationsController extends Controller properties: [ 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'], + 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'Per-service www/non-www redirect for this compose service.', 'enum' => ['www', 'non-www', 'both']], ], ), ], @@ -554,6 +555,7 @@ class ApplicationsController extends Controller properties: [ 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'], + 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'Per-service www/non-www redirect for this compose service.', 'enum' => ['www', 'non-www', 'both']], ], ), ], @@ -746,6 +748,7 @@ class ApplicationsController extends Controller properties: [ 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'], + 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'Per-service www/non-www redirect for this compose service.', 'enum' => ['www', 'non-www', 'both']], ], ), ], @@ -1332,9 +1335,10 @@ class ApplicationsController extends Controller 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable', 'docker_compose_domains' => 'array|nullable', - 'docker_compose_domains.*' => 'array:name,domain', + 'docker_compose_domains.*' => 'array:name,domain,redirect', 'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(), + 'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both', ]; // ports_exposes is not required for dockercompose if ($request->build_pack === 'dockercompose') { @@ -1343,7 +1347,7 @@ class ApplicationsController extends Controller } $validationRules = array_merge(sharedDataApplications(), $validationRules); $validationMessages = [ - 'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.', + 'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.', ]; $validator = Validator::make($request->all(), $validationRules, $validationMessages); if ($validator->fails()) { @@ -1435,7 +1439,12 @@ class ApplicationsController extends Controller } $dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) { - $dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]); + $entry = ['domain' => data_get($domain, 'domain')]; + $redirect = data_get($domain, 'redirect'); + if (in_array($redirect, ['www', 'non-www', 'both'], true)) { + $entry['redirect'] = $redirect; + } + $dockerComposeDomainsJson->put(data_get($domain, 'name'), $entry); }); $request->offsetUnset('docker_compose_domains'); } @@ -1552,13 +1561,14 @@ class ApplicationsController extends Controller 'github_app_uuid' => 'string|required', 'watch_paths' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', - 'docker_compose_domains.*' => 'array:name,domain', + 'docker_compose_domains.*' => 'array:name,domain,redirect', 'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(), + 'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both', ]; $validationRules = array_merge(sharedDataApplications(), $validationRules); $validationMessages = [ - 'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.', + 'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.', ]; $validator = Validator::make($request->all(), $validationRules, $validationMessages); if ($validator->fails()) { @@ -1688,7 +1698,12 @@ class ApplicationsController extends Controller } $dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) { - $dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]); + $entry = ['domain' => data_get($domain, 'domain')]; + $redirect = data_get($domain, 'redirect'); + if (in_array($redirect, ['www', 'non-www', 'both'], true)) { + $entry['redirect'] = $redirect; + } + $dockerComposeDomainsJson->put(data_get($domain, 'name'), $entry); }); $request->offsetUnset('docker_compose_domains'); } @@ -1804,14 +1819,15 @@ class ApplicationsController extends Controller 'private_key_uuid' => 'string|required', 'watch_paths' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', - 'docker_compose_domains.*' => 'array:name,domain', + 'docker_compose_domains.*' => 'array:name,domain,redirect', 'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(), + 'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both', ]; $validationRules = array_merge(sharedDataApplications(), $validationRules); $validationMessages = [ - 'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.', + 'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.', ]; $validator = Validator::make($request->all(), $validationRules, $validationMessages); @@ -1913,7 +1929,12 @@ class ApplicationsController extends Controller } $dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) { - $dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]); + $entry = ['domain' => data_get($domain, 'domain')]; + $redirect = data_get($domain, 'redirect'); + if (in_array($redirect, ['www', 'non-www', 'both'], true)) { + $entry['redirect'] = $redirect; + } + $dockerComposeDomainsJson->put(data_get($domain, 'name'), $entry); }); $request->offsetUnset('docker_compose_domains'); } @@ -2647,6 +2668,7 @@ class ApplicationsController extends Controller properties: [ 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'], + 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'Per-service www/non-www redirect for this compose service.', 'enum' => ['www', 'non-www', 'both']], ], ), ], @@ -2773,9 +2795,10 @@ class ApplicationsController extends Controller 'static_image' => 'string', 'watch_paths' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', - 'docker_compose_domains.*' => 'array:name,domain', + 'docker_compose_domains.*' => 'array:name,domain,redirect', 'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(), + 'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both', 'custom_nginx_configuration' => 'string|nullable', 'is_http_basic_auth_enabled' => 'boolean|nullable', 'is_preview_deployments_enabled' => 'boolean|nullable', @@ -2785,7 +2808,7 @@ class ApplicationsController extends Controller ]; $validationRules = array_merge(sharedDataApplications(), $validationRules); $validationMessages = [ - 'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.', + 'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.', ]; $validator = Validator::make($request->all(), $validationRules, $validationMessages); @@ -2996,7 +3019,12 @@ class ApplicationsController extends Controller $dockerComposeDomains->each(function ($domain) use ($services, $dockerComposeDomainsJson) { $name = data_get($domain, 'name'); if ($name && is_array($services) && isset($services[$name])) { - $dockerComposeDomainsJson->put($name, ['domain' => data_get($domain, 'domain')]); + $entry = ['domain' => data_get($domain, 'domain')]; + $redirect = data_get($domain, 'redirect'); + if (in_array($redirect, ['www', 'non-www', 'both'], true)) { + $entry['redirect'] = $redirect; + } + $dockerComposeDomainsJson->put($name, $entry); } }); $request->offsetUnset('docker_compose_domains'); diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index f3fcd68681..e0c66ec4c7 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -17,6 +17,17 @@ class Domains extends Component public string $redirect = 'both'; + /** + * Per compose-service www/non-www redirect direction. + * Keys are wire-safe (dots encoded) — use serviceRedirectWireKey(). + * + * @var array + */ + public array $serviceRedirects = []; + + /** Compose service name when a pending domain conflict belongs to setServiceRedirect. */ + public ?string $pendingRedirectService = null; + public string $newDomain = ''; public ?string $newDomainService = null; @@ -56,7 +67,7 @@ class Domains extends Component public string $editDomainDnsMessage = ''; - /** Pending save path after conflict confirmation: add | update | suggested */ + /** Pending save path after conflict confirmation: add | update | suggested | redirect */ public ?string $pendingAction = null; public bool $isCompose = false; @@ -83,7 +94,9 @@ class Domains extends Component return [ 'newDomain' => ValidationPatterns::applicationDomainRules(), 'editingDomain' => ValidationPatterns::applicationDomainRules(), - 'redirect' => 'string|required', + 'redirect' => 'string|required|in:both,www,non-www', + 'serviceRedirects' => 'array', + 'serviceRedirects.*' => 'string|in:both,www,non-www', ]; } @@ -94,6 +107,8 @@ class Domains extends Component [ 'redirect.required' => 'The Redirect setting is required.', 'redirect.string' => 'The Redirect setting must be a string.', + 'redirect.in' => 'The Redirect setting must be both, www, or non-www.', + 'serviceRedirects.*.in' => 'The Redirect setting must be both, www, or non-www.', ] ); } @@ -142,6 +157,7 @@ class Domains extends Component } $this->composeServices = []; + $this->serviceRedirects = []; if ($this->isCompose) { try { $parsed = $this->application->parse() ?? []; @@ -157,11 +173,59 @@ class Domains extends Component if ($this->newDomainService === null && count($this->composeServices) > 0) { $this->newDomainService = $this->composeServices[0]; } + + $domains = $this->application->docker_compose_domains + ? json_decode($this->application->docker_compose_domains, true) + : []; + if (! is_array($domains)) { + $domains = []; + } + + $serviceNames = $this->composeServices; + foreach (array_keys($domains) as $serviceName) { + if (! in_array($serviceName, $serviceNames, true)) { + $serviceNames[] = $serviceName; + } + } + + foreach ($serviceNames as $serviceName) { + // data_get treats dots as path separators; read the service entry by array key. + $serviceEntry = $domains[$serviceName] ?? null; + $storedRedirect = is_array($serviceEntry) ? ($serviceEntry['redirect'] ?? null) : null; + $this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] = $this->normalizeRedirect( + is_string($storedRedirect) ? $storedRedirect : null + ); + } } $this->domainRows = $this->buildDomainRows(); } + /** + * Livewire wire:model treats dots as nesting (serviceRedirects.api.test). + * Encode them so compose service names like "api.test" stay flat string values. + */ + public function serviceRedirectWireKey(string $serviceName): string + { + return str_replace('.', '__dot__', $serviceName); + } + + protected function normalizeRedirect(?string $redirect): string + { + return in_array($redirect, ['www', 'non-www', 'both'], true) ? $redirect : 'both'; + } + + protected function serviceRedirectFor(?string $serviceName): string + { + if ($serviceName === null) { + return $this->normalizeRedirect($this->redirect); + } + + return $this->normalizeRedirect( + $this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] ?? null + ); + } + /** * @return array */ @@ -188,7 +252,10 @@ class Domains extends Component foreach ($serviceNames as $serviceName) { $configured = []; - $domainString = data_get($domains, "{$serviceName}.domain"); + // Array key access: data_get() would treat dots in service names as path separators. + $domainString = is_array($domains[$serviceName] ?? null) + ? ($domains[$serviceName]['domain'] ?? null) + : null; foreach ($this->splitDomains(is_string($domainString) ? $domainString : null) as $url) { $row = $this->domainRowFromStored($url, $serviceName, $stored); $rows[] = $row; @@ -254,8 +321,7 @@ class Domains extends Component $base = $this->domainRowFromStored($counterpart, $rowService, $stored); $isWww = str_starts_with($hostKey, 'www.'); - // Compose has no Direction UI: always use simple pair messaging. - $meta = $this->suggestedDomainMeta($isWww, $this->isCompose ? 'both' : null); + $meta = $this->suggestedDomainMeta($isWww, $this->serviceRedirectFor(is_string($rowService) ? $rowService : null)); $base['is_suggested'] = true; $base['suggested_for'] = $url; @@ -476,7 +542,11 @@ class Domains extends Component // Clarify purpose for redirect-source / canonical suggested hosts. if ($this->domainRows[$index]['is_suggested'] ?? false) { $isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.'); - $meta = $this->suggestedDomainMeta($isWww, $this->isCompose ? 'both' : null); + $serviceName = $this->domainRows[$index]['service'] ?? null; + $meta = $this->suggestedDomainMeta( + $isWww, + $this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null) + ); if ($meta['dns_suffix'] !== '') { $this->domainRows[$index]['dns_message'] = trim($this->domainRows[$index]['dns_message'].' '.$meta['dns_suffix']); } @@ -625,6 +695,18 @@ class Domains extends Component return; } + if ($this->pendingAction === 'redirect') { + if ($this->isCompose && filled($this->pendingRedirectService)) { + $this->setServiceRedirect($this->pendingRedirectService); + + return; + } + + $this->setRedirect(); + + return; + } + $this->addDomain(); } @@ -738,14 +820,6 @@ class Domains extends Component $this->rememberDomainDnsResults($urls, 'ok', $message, $service); } - /** - * Refresh suggested www/non-www rows when Direction changes (before Set Direction is saved). - */ - public function updatedRedirect(): void - { - $this->domainRows = $this->buildDomainRows(); - } - protected function shouldValidateDnsForAdd(): bool { if (! $this->dnsValidationEnabled) { @@ -1078,42 +1152,226 @@ class Domains extends Component { try { $this->authorize('update', $this->application); - $this->validateOnly('redirect'); - $this->application->redirect = $this->redirect; - $hasWww = collect($this->application->fqdns)->filter( - fn ($fqdn) => str_starts_with(strtolower((string) $this->domainHost($fqdn)), 'www.') - )->count(); - $hasNonWww = collect($this->application->fqdns)->filter( - fn ($fqdn) => ! str_starts_with(strtolower((string) $this->domainHost($fqdn)), 'www.') - )->count(); - - $dnsHint = dnsMismatchGuidanceMessage( - $this->dnsTargetLabel() ?? $this->serverIp, - $this->serverIp, - ); - - if ($hasWww === 0 && $this->application->redirect === 'www') { - $this->dispatch('error', "You want to redirect to www, but you do not have a www domain set.

Please add www to your domain list ({$dnsHint})."); + if ($this->isCompose) { + $this->dispatch('error', 'Set the redirect direction per compose service.'); return; } - if ($hasNonWww === 0 && $this->application->redirect === 'non-www') { - $this->dispatch('error', "You want to redirect to non-www, but you do not have a non-www domain set.

Please add the apex domain to your domain list ({$dnsHint})."); + $this->validateOnly('redirect'); + $this->application->redirect = $this->redirect; + + // 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. + if (in_array($this->redirect, ['www', 'non-www'], true)) { + if (! $this->ensureWwwNonWwwPairsConfigured(null)) { + return; + } + + $this->application->refresh(); + $this->application->redirect = $this->redirect; + } + + $domains = collect($this->application->fqdns); + if (! $this->assertRedirectDomainsPresent($this->redirect, $domains)) { return; } $this->application->save(); + $this->pendingAction = null; + $this->pendingRedirectService = null; + $this->forceSaveDomains = false; $this->resetDefaultLabels(); $this->dispatch('success', 'Redirect updated.'); $this->refreshDomains(); + $this->pruneDomainDnsStatusesToCurrentDomains(); } catch (\Throwable $e) { handleError($e, $this); } } + /** + * @param mixed ...$modalArgs Extra args from modal-confirmation (password, etc.) + */ + public function setServiceRedirect(string $serviceName, mixed ...$modalArgs): void + { + try { + $this->authorize('update', $this->application); + + if (! $this->isCompose) { + $this->dispatch('error', 'Per-service redirect is only available for Docker Compose applications.'); + + return; + } + + // modal-confirmation passes string args with surrounding quotes intact. + $serviceName = trim($serviceName, " \t\n\r\0\x0B'\""); + + if (blank($serviceName)) { + $this->dispatch('error', 'A service is required.'); + + return; + } + + // Drop any nested arrays left from broken wire:model paths (e.g. service names with dots). + $this->serviceRedirects = collect($this->serviceRedirects) + ->filter(fn ($value) => is_string($value) || is_numeric($value)) + ->map(fn ($value) => $this->normalizeRedirect(is_string($value) ? $value : (string) $value)) + ->all(); + + $wireKey = $this->serviceRedirectWireKey($serviceName); + if (! array_key_exists($wireKey, $this->serviceRedirects)) { + $this->serviceRedirects[$wireKey] = 'both'; + } + + $this->validateOnly("serviceRedirects.{$wireKey}"); + $redirect = $this->normalizeRedirect($this->serviceRedirects[$wireKey] ?? null); + $this->serviceRedirects[$wireKey] = $redirect; + $this->pendingRedirectService = $serviceName; + + // Promote the optional www/non-www suggestion to a real domain for redirects. + if (in_array($redirect, ['www', 'non-www'], true)) { + if (! $this->ensureWwwNonWwwPairsConfigured($serviceName)) { + return; + } + // Ensure we re-read domains after pair save before writing redirect. + $this->application->refresh(); + } + + $allDomains = $this->application->docker_compose_domains + ? json_decode($this->application->docker_compose_domains, true) + : []; + if (! is_array($allDomains)) { + $allDomains = []; + } + + $existing = is_array($allDomains[$serviceName] ?? null) ? $allDomains[$serviceName] : []; + $allDomains[$serviceName] = array_merge($existing, [ + 'redirect' => $redirect, + ]); + + // Keep domain key present when only redirect is set. + if (! array_key_exists('domain', $allDomains[$serviceName])) { + $allDomains[$serviceName]['domain'] = null; + } + + $this->application->docker_compose_domains = json_encode($allDomains); + $this->application->save(); + + $domains = $this->currentDomainList($serviceName); + if (! $this->assertRedirectDomainsPresent($redirect, $domains)) { + return; + } + + $this->pendingAction = null; + $this->pendingRedirectService = null; + $this->forceSaveDomains = false; + $this->resetDefaultLabels(); + $this->dispatch('success', "Redirect updated for {$serviceName}."); + $this->refreshDomains(); + $this->pruneDomainDnsStatusesToCurrentDomains(); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + /** + * @param Collection $domains + */ + protected function assertRedirectDomainsPresent(string $redirect, Collection $domains): bool + { + if (! in_array($redirect, ['www', 'non-www'], true)) { + return true; + } + + $hasWww = $domains->filter( + fn ($fqdn) => str_starts_with(strtolower((string) $this->domainHost((string) $fqdn)), 'www.') + )->count(); + $hasNonWww = $domains->filter( + function ($fqdn) { + $host = strtolower((string) $this->domainHost((string) $fqdn)); + + return $host !== '' && ! str_starts_with($host, 'www.'); + } + )->count(); + + $dnsHint = dnsMismatchGuidanceMessage( + $this->dnsTargetLabel() ?? $this->serverIp, + $this->serverIp, + ); + + // Redirects need both hosts: canonical target + source the proxy redirects from. + if ($hasWww === 0 || $hasNonWww === 0) { + $missing = $hasWww === 0 ? 'www' : 'non-www'; + $this->dispatch( + 'error', + "Redirect requires both www and non-www domains, but the {$missing} host could not be added automatically (e.g. only IP/sslip hosts).

Please add the {$missing} domain manually ({$dnsHint})." + ); + + return false; + } + + return true; + } + + /** + * Persist missing www/non-www counterparts as normal domains (not suggestions). + * + * @return bool false when save was blocked (e.g. domain conflict modal shown) + */ + protected function ensureWwwNonWwwPairsConfigured(?string $serviceName = null): bool + { + $current = $this->currentDomainList($serviceName); + $knownHosts = []; + + foreach ($current as $url) { + $host = $this->domainHost($url); + if ($host !== null) { + $knownHosts[strtolower($host)] = true; + } + } + + $toAdd = collect(); + foreach ($current as $url) { + // Include sslip/nip so compose/dev hosts can still get redirect pairs. + $counterpart = $this->wwwCounterpartUrl($url, forRedirectPairing: true); + if ($counterpart === null) { + continue; + } + + $counterpartHost = $this->domainHost($counterpart); + if ($counterpartHost === null) { + continue; + } + + $hostKey = strtolower($counterpartHost); + if (isset($knownHosts[$hostKey])) { + continue; + } + + $knownHosts[$hostKey] = true; + $toAdd->push($counterpart); + } + + if ($toAdd->isEmpty()) { + return true; + } + + $merged = $current->merge($toAdd)->unique()->values(); + $this->pendingAction = 'redirect'; + $this->pendingRedirectService = $serviceName; + + // Skip DNS: pairing for redirects must still be configured even when DNS is not ready. + if (! $this->saveDomainList($merged, $serviceName, checkDns: false)) { + return false; + } + + return true; + } + protected function domainHost(string $url): ?string { try { @@ -1125,7 +1383,13 @@ class Domains extends Component } } - protected function wwwCounterpartUrl(string $url): ?string + /** + * Build the www/non-www counterpart URL for a host. + * + * @param bool $forRedirectPairing When true, also pair sslip/nip hosts so www↔non-www + * redirects can be configured (suggestions still skip them). + */ + protected function wwwCounterpartUrl(string $url, bool $forRedirectPairing = false): ?string { $host = $this->domainHost($url); if ($host === null) { @@ -1134,12 +1398,16 @@ class Domains extends Component $lowerHost = strtolower($host); - // Skip IPs, localhost, and auto-generated sslip domains. + // Always skip bare IPs and localhost. + if (filter_var($host, FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') { + return null; + } + + // Optional suggestions skip auto-generated hosts; redirect pairing includes them so + // Set Direction can promote the missing side to a real domain. if ( - filter_var($host, FILTER_VALIDATE_IP) !== false - || $lowerHost === 'localhost' - || str_contains($lowerHost, 'sslip.io') - || str_contains($lowerHost, 'nip.io') + ! $forRedirectPairing + && (str_contains($lowerHost, 'sslip.io') || str_contains($lowerHost, 'nip.io')) ) { return null; } @@ -1180,9 +1448,11 @@ class Domains extends Component $domains = []; } - $domainString = data_get($domains, "{$serviceName}.domain"); + $domainString = is_array($domains[$serviceName] ?? null) + ? ($domains[$serviceName]['domain'] ?? null) + : null; - return collect($this->splitDomains($domainString)); + return collect($this->splitDomains(is_string($domainString) ? $domainString : null)); } return collect($this->splitDomains($this->application->fqdn)); @@ -1224,19 +1494,12 @@ class Domains extends Component $allDomains = []; } - if ($domainString === null) { - if (isset($allDomains[$serviceName])) { - unset($allDomains[$serviceName]['domain']); - if (empty(array_filter($allDomains[$serviceName] ?? []))) { - // Keep service key with empty domain for compose structure stability - $allDomains[$serviceName] = ['domain' => null]; - } - } else { - $allDomains[$serviceName] = ['domain' => null]; - } - } else { - $allDomains[$serviceName] = array_merge($allDomains[$serviceName] ?? [], ['domain' => $domainString]); - } + $existing = is_array($allDomains[$serviceName] ?? null) ? $allDomains[$serviceName] : []; + // Preserve stored redirect only — pending Direction dropdown values must not + // persist until setServiceRedirect() runs. + $allDomains[$serviceName] = array_merge($existing, [ + 'domain' => $domainString, + ]); $this->application->docker_compose_domains = json_encode($allDomains); $this->application->fqdn = null; diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index e0cebf615c..67b2c6fdf3 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -19,6 +19,16 @@ class Domains extends Component /** @var array */ public array $serviceApps = []; + /** + * Per service-application www/non-www redirect direction. + * + * @var array + */ + public array $serviceRedirects = []; + + /** Service application id when a pending domain conflict belongs to setServiceRedirect. */ + public ?int $pendingRedirectServiceApplicationId = null; + /** @var array> */ public array $domainRows = []; @@ -83,6 +93,8 @@ class Domains extends Component 'newDomain' => ValidationPatterns::applicationDomainRules(), 'editingDomain' => ValidationPatterns::applicationDomainRules(), 'newServiceApplicationId' => 'nullable|integer', + 'serviceRedirects' => 'array', + 'serviceRedirects.*' => 'string|in:both,www,non-www', ]; } @@ -134,6 +146,11 @@ class Domains extends Component ]) ->all(); + $this->serviceRedirects = []; + foreach ($this->service->applications as $app) { + $this->serviceRedirects[$app->id] = $this->normalizeRedirect($app->redirect ?? null); + } + if ($this->newServiceApplicationId === null && count($this->serviceApps) > 0) { $this->newServiceApplicationId = $this->serviceApps[0]['id']; } @@ -141,6 +158,20 @@ class Domains extends Component $this->domainRows = $this->buildDomainRows(); } + protected function normalizeRedirect(?string $redirect): string + { + return in_array($redirect, ['www', 'non-www', 'both'], true) ? $redirect : 'both'; + } + + protected function serviceRedirectFor(?int $serviceApplicationId): string + { + if (! $serviceApplicationId) { + return 'both'; + } + + return $this->normalizeRedirect($this->serviceRedirects[$serviceApplicationId] ?? null); + } + /** * @return array> */ @@ -244,15 +275,20 @@ class Domains extends Component $base = $this->domainRowFromStored($counterpart, $app, $stored); $isWww = str_starts_with($hostKey, 'www.'); - $pointDns = dnsMismatchGuidanceMessage($this->dnsTargetLabel(), $this->serverIp); + $meta = $this->suggestedDomainMeta($isWww, $this->serviceRedirectFor($app->id)); $base['is_suggested'] = true; $base['suggested_for'] = $url; - $base['suggestion_label'] = $isWww ? 'Suggested www' : 'Suggested non-www'; + $base['suggestion_label'] = $meta['label']; + $base['suggestion_role'] = $meta['role']; $base['needs_force_add'] = false; if (($base['dns_status'] ?? 'pending') === 'pending') { - $base['dns_message'] = "Also add this host so both www and non-www work. {$pointDns}"; + $base['dns_message'] = $meta['pending_message']; + } elseif (in_array($base['dns_status'], ['ok', 'failed', 'skipped'], true)) { + if ($meta['role'] !== 'pair' && ! str_contains((string) $base['dns_message'], 'redirect')) { + $base['dns_message'] = trim((string) $base['dns_message'].' '.$meta['dns_suffix']); + } } $suggested[] = $base; @@ -465,9 +501,216 @@ class Domains extends Component return; } + if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) { + $this->setServiceRedirect((int) $this->pendingRedirectServiceApplicationId); + + return; + } + $this->addDomain(); } + /** + * Labels/copy for a suggested www or non-www host based on Direction. + * + * @return array{label: string, role: string, pending_message: string, dns_suffix: string} + */ + protected function suggestedDomainMeta(bool $suggestedIsWww, ?string $redirectOverride = null): array + { + $pointDns = dnsMismatchGuidanceMessage($this->dnsTargetLabel(), $this->serverIp); + $redirect = $this->normalizeRedirect($redirectOverride); + + return match ($redirect) { + 'www' => $suggestedIsWww + ? [ + 'label' => 'Canonical www', + 'role' => 'canonical', + 'pending_message' => "Required as the redirect target (www). {$pointDns}", + 'dns_suffix' => 'This is the canonical www host traffic should land on.', + ] + : [ + 'label' => 'Redirect source', + 'role' => 'redirect_source', + 'pending_message' => "Needed so Coolify can redirect non-www to www. {$pointDns}", + 'dns_suffix' => 'Used only so Coolify can redirect this host to www. Still needs DNS to the server, not a provider URL-redirect record.', + ], + 'non-www' => $suggestedIsWww + ? [ + 'label' => 'Redirect source', + 'role' => 'redirect_source', + 'pending_message' => "Needed so Coolify can redirect www to non-www. {$pointDns}", + 'dns_suffix' => 'Used only so Coolify can redirect this host to non-www. Still needs DNS to the server, not a provider URL-redirect record.', + ] + : [ + 'label' => 'Canonical non-www', + 'role' => 'canonical', + 'pending_message' => "Required as the redirect target (non-www). {$pointDns}", + 'dns_suffix' => 'This is the canonical non-www host traffic should land on.', + ], + default => [ + 'label' => $suggestedIsWww ? 'Suggested www' : 'Suggested non-www', + 'role' => 'pair', + 'pending_message' => "Also add this host so both www and non-www work. {$pointDns}", + 'dns_suffix' => '', + ], + }; + } + + /** + * @param mixed ...$modalArgs Extra args from modal-confirmation (password, etc.) + */ + public function setServiceRedirect(int $serviceApplicationId, mixed ...$modalArgs): void + { + try { + $this->authorize('update', $this->service); + + $app = $this->findServiceApp($serviceApplicationId); + if (! $app) { + $this->dispatch('error', 'Service application not found.'); + + return; + } + + $this->validateOnly("serviceRedirects.{$serviceApplicationId}"); + $redirect = $this->normalizeRedirect($this->serviceRedirects[$serviceApplicationId] ?? null); + $this->serviceRedirects[$serviceApplicationId] = $redirect; + $this->pendingRedirectServiceApplicationId = $serviceApplicationId; + + // Promote the optional www/non-www suggestion to a real domain for redirects. + if (in_array($redirect, ['www', 'non-www'], true)) { + if (! $this->ensureWwwNonWwwPairsConfigured($app)) { + return; + } + $app->refresh(); + } + + $app->redirect = $redirect; + $app->save(); + + $domains = collect($this->splitDomains($app->fqdn)); + if (! $this->assertRedirectDomainsPresent($redirect, $domains)) { + return; + } + + try { + updateCompose($app); + } catch (\Throwable) { + // Compose generation may fail in incomplete test environments. + } + + try { + $this->service->parse(); + } catch (\Throwable) { + // Parse may fail without a full compose template. + } + + $this->pendingAction = null; + $this->pendingRedirectServiceApplicationId = null; + $this->forceSaveDomains = false; + $this->forceRemovePort = false; + $this->dispatch('success', 'Redirect updated.'); + $this->dispatch('refresh'); + $this->dispatch('refreshServices'); + $this->dispatch('configurationChanged'); + $this->pruneDomainDnsStatusesToCurrentDomains(); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + /** + * @param Collection $domains + */ + protected function assertRedirectDomainsPresent(string $redirect, Collection $domains): bool + { + if (! in_array($redirect, ['www', 'non-www'], true)) { + return true; + } + + $hasWww = $domains->filter( + fn ($fqdn) => str_starts_with(strtolower((string) $this->domainHost((string) $fqdn)), 'www.') + )->count(); + $hasNonWww = $domains->filter( + function ($fqdn) { + $host = strtolower((string) $this->domainHost((string) $fqdn)); + + return $host !== '' && ! str_starts_with($host, 'www.'); + } + )->count(); + + $dnsHint = dnsMismatchGuidanceMessage( + $this->dnsTargetLabel() ?? $this->serverIp, + $this->serverIp, + ); + + // Redirects need both hosts: canonical target + source the proxy redirects from. + if ($hasWww === 0 || $hasNonWww === 0) { + $missing = $hasWww === 0 ? 'www' : 'non-www'; + $this->dispatch( + 'error', + "Redirect requires both www and non-www domains, but the {$missing} host could not be added automatically (e.g. only IP/sslip hosts).

Please add the {$missing} domain manually ({$dnsHint})." + ); + + return false; + } + + return true; + } + + /** + * Persist missing www/non-www counterparts as normal domains (not suggestions). + * + * @return bool false when save was blocked (e.g. domain conflict modal shown) + */ + protected function ensureWwwNonWwwPairsConfigured(ServiceApplication $app): bool + { + $current = collect($this->splitDomains($app->fqdn)); + $knownHosts = []; + + foreach ($current as $url) { + $host = $this->domainHost($url); + if ($host !== null) { + $knownHosts[strtolower($host)] = true; + } + } + + $toAdd = collect(); + foreach ($current as $url) { + $counterpart = $this->wwwCounterpartUrl($url, forRedirectPairing: true); + if ($counterpart === null) { + continue; + } + + $counterpartHost = $this->domainHost($counterpart); + if ($counterpartHost === null) { + continue; + } + + $hostKey = strtolower($counterpartHost); + if (isset($knownHosts[$hostKey])) { + continue; + } + + $knownHosts[$hostKey] = true; + $toAdd->push($counterpart); + } + + if ($toAdd->isEmpty()) { + return true; + } + + $merged = $current->merge($toAdd)->unique()->values(); + $this->pendingAction = 'redirect'; + $this->pendingRedirectServiceApplicationId = $app->id; + + // Skip DNS: pairing for redirects must still be configured even when DNS is not ready. + if (! $this->saveDomainListForApp($app, $merged, checkDns: false)) { + return false; + } + + return true; + } + public function confirmRemovePort(): void { $this->forceRemovePort = true; @@ -994,7 +1237,10 @@ class Domains extends Component } } - protected function wwwCounterpartUrl(string $url): ?string + /** + * @param bool $forRedirectPairing When true, also pair sslip/nip hosts for www↔non-www redirects. + */ + protected function wwwCounterpartUrl(string $url, bool $forRedirectPairing = false): ?string { $host = $this->domainHost($url); if ($host === null) { @@ -1002,11 +1248,14 @@ class Domains extends Component } $lowerHost = strtolower($host); + + if (filter_var($host, FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') { + return null; + } + if ( - filter_var($host, FILTER_VALIDATE_IP) !== false - || $lowerHost === 'localhost' - || str_contains($lowerHost, 'sslip.io') - || str_contains($lowerHost, 'nip.io') + ! $forRedirectPairing + && (str_contains($lowerHost, 'sslip.io') || str_contains($lowerHost, 'nip.io')) ) { return null; } diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 7ab81224c4..99e52f2880 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -17,6 +17,7 @@ class ServiceApplication extends BaseModel 'human_name', 'description', 'fqdn', + 'redirect', 'domain_dns_statuses', 'ports', 'exposes', diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index ff1d6563ec..66dbe9f90a 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1352,6 +1352,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($isPullRequest) { $labelNetwork = "{$resource->destination->network}-{$pullRequestId}"; } + $composeRedirect = data_get($domains, "$changedServiceName.redirect"); + $redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true) + ? $composeRedirect + : 'both'; if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: @@ -1363,7 +1367,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, - image: $image + image: $image, + redirect_direction: $redirectDirection, )); break; case ProxyTypes::CADDY->value: @@ -1377,7 +1382,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, - predefinedPort: $predefinedPort + predefinedPort: $predefinedPort, + redirect_direction: $redirectDirection, )); break; } @@ -1390,7 +1396,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, - image: $image + image: $image, + redirect_direction: $redirectDirection, )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $labelNetwork, @@ -1402,7 +1409,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, - predefinedPort: $predefinedPort + predefinedPort: $predefinedPort, + redirect_direction: $redirectDirection, )); } } @@ -2615,6 +2623,9 @@ function serviceParser(Service $resource): Collection $shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels; $uuid = $resource->uuid; $network = data_get($resource, 'destination.network'); + $redirectDirection = in_array(data_get($originalResource, 'redirect'), ['www', 'non-www', 'both'], true) + ? data_get($originalResource, 'redirect') + : 'both'; if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: @@ -2626,7 +2637,8 @@ function serviceParser(Service $resource): Collection is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, - image: $image + image: $image, + redirect_direction: $redirectDirection, )); break; case ProxyTypes::CADDY->value: @@ -2640,7 +2652,8 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, - predefinedPort: $predefinedPort + predefinedPort: $predefinedPort, + redirect_direction: $redirectDirection, )); break; } @@ -2653,7 +2666,8 @@ function serviceParser(Service $resource): Collection is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, - image: $image + image: $image, + redirect_direction: $redirectDirection, )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $network, @@ -2665,7 +2679,8 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, - predefinedPort: $predefinedPort + predefinedPort: $predefinedPort, + redirect_direction: $redirectDirection, )); } } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 019a124844..4e49358a7e 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -2915,6 +2915,9 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if (! $isDatabase && $fqdns->count() > 0) { if ($fqdns) { $shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels; + $redirectDirection = in_array(data_get($savedService, 'redirect'), ['www', 'non-www', 'both'], true) + ? data_get($savedService, 'redirect') + : 'both'; if ($shouldGenerateLabelsExactly) { switch ($resource->server->proxyType()) { case ProxyTypes::TRAEFIK->value: @@ -2926,7 +2929,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, - image: data_get($service, 'image') + image: data_get($service, 'image'), + redirect_direction: $redirectDirection, )); break; case ProxyTypes::CADDY->value: @@ -2939,7 +2943,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, - image: data_get($service, 'image') + image: data_get($service, 'image'), + redirect_direction: $redirectDirection, )); break; } @@ -2952,7 +2957,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, - image: data_get($service, 'image') + image: data_get($service, 'image'), + redirect_direction: $redirectDirection, )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $resource->destination->network, @@ -2963,7 +2969,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, - image: data_get($service, 'image') + image: data_get($service, 'image'), + redirect_direction: $redirectDirection, )); } } @@ -3689,6 +3696,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } $shouldGenerateLabelsExactly = $server->settings->generate_exact_labels; + $composeRedirect = data_get($domains, "$serviceName.redirect"); + $redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true) + ? $composeRedirect + : 'both'; if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: @@ -3702,6 +3713,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(), + redirect_direction: $redirectDirection, ) ); break; @@ -3716,6 +3728,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(), + redirect_direction: $redirectDirection, ) ); break; @@ -3731,6 +3744,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(), + redirect_direction: $redirectDirection, ) ); $serviceLabels = $serviceLabels->merge( @@ -3743,6 +3757,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(), + redirect_direction: $redirectDirection, ) ); } diff --git a/database/migrations/2026_07_29_100747_add_redirect_to_service_applications_table.php b/database/migrations/2026_07_29_100747_add_redirect_to_service_applications_table.php new file mode 100644 index 0000000000..42b46c0e50 --- /dev/null +++ b/database/migrations/2026_07_29_100747_add_redirect_to_service_applications_table.php @@ -0,0 +1,28 @@ +enum('redirect', ['www', 'non-www', 'both'])->default('both')->after('fqdn'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('service_applications', function (Blueprint $table) { + $table->dropColumn('redirect'); + }); + } +}; diff --git a/package.json b/package.json index d423b5a5c6..9e94054df1 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", + "migrate": "docker exec coolify php artisan migrate --force --no-interaction", + "migrate:seed": "docker exec coolify php artisan migrate:fresh --seed --force --no-interaction", "clean": "docker compose -f docker-compose.yml -f docker-compose.dev.yml down --remove-orphans" }, "devDependencies": { diff --git a/resources/views/livewire/project/application/domains.blade.php b/resources/views/livewire/project/application/domains.blade.php index 0e25da6ee3..c981f9936f 100644 --- a/resources/views/livewire/project/application/domains.blade.php +++ b/resources/views/livewire/project/application/domains.blade.php @@ -98,8 +98,8 @@ @endif @else
- + @@ -177,6 +177,37 @@
+ @unless ($labelsAreWritable) + @php + $redirectWireKey = $this->serviceRedirectWireKey($serviceName); + @endphp +
+
+ + + + + +
+ @can('update', $application) + {{-- Single-quoted attr so Js::from double-quotes don't break HTML. --}} + + +
Set Direction
+
+
+ @endcan +
+ @endunless + @if ($rows->isEmpty())
diff --git a/resources/views/livewire/project/service/domains.blade.php b/resources/views/livewire/project/service/domains.blade.php index a175797a31..1ee4662012 100644 --- a/resources/views/livewire/project/service/domains.blade.php +++ b/resources/views/livewire/project/service/domains.blade.php @@ -131,6 +131,32 @@
+
+
+ + + + + +
+ @can('update', $service) + + +
Set Direction
+
+
+ @endcan +
+ @foreach ($rows as $row) @php $index = collect($domainRows)->search( diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 4b394c873a..0629ba5405 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -276,7 +276,7 @@ it('sets redirect direction when www domain exists', function () { expect($this->application->redirect)->toBe('www'); }); -it('rejects www redirect when no www domain exists', function () { +it('auto-adds missing www counterpart as a normal domain when setting www redirect', function () { $this->application->update([ 'fqdn' => 'https://example.com', 'redirect' => 'both', @@ -285,11 +285,71 @@ it('rejects www redirect when no www domain exists', function () { Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('redirect', 'www') ->call('setRedirect') - ->assertDispatched('error'); + ->assertDispatched('success') + ->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'); $this->application->refresh(); - expect($this->application->redirect)->toBe('both'); + expect($this->application->redirect)->toBe('www') + ->and(explode(',', (string) $this->application->fqdn)) + ->toContain('https://example.com') + ->toContain('https://www.example.com'); +}); + +it('auto-adds missing non-www counterpart as a normal domain when setting non-www redirect', function () { + $this->application->update([ + 'fqdn' => 'https://www.example.com', + 'redirect' => 'both', + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->set('redirect', 'non-www') + ->call('setRedirect') + ->assertDispatched('success'); + + $this->application->refresh(); + + expect($this->application->redirect)->toBe('non-www') + ->and(explode(',', (string) $this->application->fqdn)) + ->toContain('https://www.example.com') + ->toContain('https://example.com'); +}); + +it('saves redirect after confirming a conflict for an auto-added www pair', function () { + Application::factory()->create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'WWW Taken App', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'fqdn' => 'https://www.example.com', + 'build_pack' => 'nixpacks', + ]); + + $this->application->update([ + 'fqdn' => 'https://example.com', + 'redirect' => 'both', + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->set('redirect', 'www') + ->call('setRedirect') + ->assertSet('showDomainConflictModal', true) + ->assertSet('pendingAction', 'redirect') + ->call('confirmDomainUsage') + ->assertSet('showDomainConflictModal', false) + ->assertSet('pendingAction', null) + ->assertDispatched('success'); + + $this->application->refresh(); + + expect($this->application->redirect)->toBe('www') + ->and(explode(',', (string) $this->application->fqdn)) + ->toContain('https://example.com') + ->toContain('https://www.example.com'); }); it('marks dns status as skipped when dns validation is disabled', function () { @@ -499,24 +559,172 @@ it('shows the missing www counterpart as a suggested domain row', function () { ->assertSee('https://www.example.com'); }); -it('changes suggested domain labels when redirect direction changes', function () { +it('does not change suggested domain labels or persist until Set Direction saves', function () { $this->application->update([ 'fqdn' => 'https://example.com', 'redirect' => 'both', ]); - Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->assertSet('domainRows.1.suggestion_label', 'Suggested www') ->assertSet('domainRows.1.suggestion_role', 'pair') ->set('redirect', 'www') - ->assertSet('domainRows.1.suggestion_label', 'Canonical www') - ->assertSet('domainRows.1.suggestion_role', 'canonical') - ->assertSee('redirect target') - ->set('redirect', 'non-www') - ->assertSet('domainRows.1.suggestion_label', 'Redirect source') - ->assertSet('domainRows.1.suggestion_role', 'redirect_source') - ->assertSee('redirect www to non-www') - ->assertSee('A record'); + // Dropdown alone must not rebuild suggestions or persist redirect. + ->assertSet('domainRows.1.suggestion_label', 'Suggested www') + ->assertSet('domainRows.1.suggestion_role', 'pair'); + + expect($this->application->fresh()->redirect)->toBe('both'); + + $component + ->call('setRedirect') + ->assertDispatched('success'); + + $this->application->refresh(); + expect($this->application->redirect)->toBe('www') + ->and(explode(',', (string) $this->application->fqdn)) + ->toContain('https://example.com') + ->toContain('https://www.example.com'); +}); + +it('does not persist compose service redirect until Set Direction is called', 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']) + ->set('serviceRedirects.web', 'www'); + + $this->application->refresh(); + $domains = json_decode($this->application->docker_compose_domains, true); + + expect(data_get($domains, 'web.redirect'))->toBe('both'); +}); + +it('sets redirect for compose services whose names contain dots', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'fqdn' => null, + 'docker_compose_raw' => "services:\n api:\n image: node:alpine\n api.test:\n image: nginx:alpine\n", + 'docker_compose_domains' => json_encode([ + 'api' => ['domain' => 'https://api.example.com', 'redirect' => 'both'], + 'api.test' => ['domain' => 'https://api-test.example.com', 'redirect' => 'both'], + ]), + ]); + + $wireKey = str_replace('.', '__dot__', 'api.test'); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->set('isCompose', true) + ->set('composeServices', ['api', 'api.test']) + ->set("serviceRedirects.{$wireKey}", 'non-www') + ->call('setServiceRedirect', 'api.test') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $this->application->refresh(); + $domains = json_decode($this->application->docker_compose_domains, true); + + expect($domains['api.test']['redirect'] ?? null)->toBe('non-www') + ->and($domains['api']['redirect'] ?? null)->toBe('both') + // api remains a string-valued sibling, not nested by the dotted service binding + ->and($domains['api']['domain'] ?? null)->toBe('https://api.example.com'); +}); + +it('accepts service names wrapped in quotes from modal-confirmation', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'fqdn' => null, + 'docker_compose_raw' => "services:\n api:\n image: nginx:alpine\n", + 'docker_compose_domains' => json_encode([ + 'api' => ['domain' => 'https://api.example.com', 'redirect' => 'both'], + ]), + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->set('isCompose', true) + ->set('composeServices', ['api']) + ->set('serviceRedirects.api', 'www') + // modal-confirmation historically passed quoted string params + empty password + ->call('setServiceRedirect', '"api"', '') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $this->application->refresh(); + $domains = json_decode($this->application->docker_compose_domains, true); + + expect($domains['api']['redirect'] ?? null)->toBe('www') + ->and(explode(',', (string) ($domains['api']['domain'] ?? ''))) + ->toContain('https://api.example.com') + ->toContain('https://www.api.example.com'); +}); + +it('auto-adds www pair for compose sslip domains when setting redirect', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'fqdn' => null, + 'docker_compose_raw' => "services:\n api:\n image: nginx:alpine\n", + 'docker_compose_domains' => json_encode([ + 'api' => [ + 'domain' => 'http://api-docker-compose.127.0.0.1.sslip.io', + 'redirect' => 'both', + ], + ]), + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->set('isCompose', true) + ->set('composeServices', ['api']) + ->set('serviceRedirects.api', 'www') + ->call('setServiceRedirect', 'api') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $this->application->refresh(); + $domains = json_decode($this->application->docker_compose_domains, true); + $apiDomains = explode(',', (string) ($domains['api']['domain'] ?? '')); + + expect($domains['api']['redirect'] ?? null)->toBe('www') + ->and($apiDomains)->toContain('http://api-docker-compose.127.0.0.1.sslip.io') + ->and($apiDomains)->toContain('http://www.api-docker-compose.127.0.0.1.sslip.io'); +}); + +it('recovers when serviceRedirects.api is corrupted to a nested array by dotted service names', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'fqdn' => null, + 'docker_compose_raw' => "services:\n api:\n image: node:alpine\n api.test:\n image: nginx:alpine\n", + 'docker_compose_domains' => json_encode([ + 'api' => ['domain' => 'https://api.example.com', 'redirect' => 'both'], + 'api.test' => ['domain' => 'https://api-test.example.com', 'redirect' => 'both'], + ]), + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->set('isCompose', true) + ->set('composeServices', ['api', 'api.test']) + // Simulate broken nested state from wire:model="serviceRedirects.api.test" + ->set('serviceRedirects', [ + 'api' => ['test' => 'www'], + 'api__dot__test' => 'both', + ]) + ->set('serviceRedirects.api', 'www') + ->call('setServiceRedirect', 'api') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $this->application->refresh(); + $domains = json_decode($this->application->docker_compose_domains, true); + + expect($domains['api']['redirect'] ?? null)->toBe('www') + ->and(explode(',', (string) ($domains['api']['domain'] ?? ''))) + ->toContain('https://www.api.example.com'); }); it('checks dns on suggested www domain rows', function () { @@ -719,3 +927,84 @@ it('exposes the domains route in the application configuration menu', function ( ->assertSeeLivewire(Domains::class) ->assertSee('Domains'); }); + +it('sets redirect direction per compose service without changing other services', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'fqdn' => null, + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: node:alpine\n", + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://web.example.com', 'redirect' => 'both'], + 'api' => ['domain' => 'https://api.example.com,https://www.api.example.com', 'redirect' => 'both'], + ]), + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->set('isCompose', true) + ->set('composeServices', ['web', 'api']) + ->set('serviceRedirects.web', 'www') + ->call('setServiceRedirect', 'web') + ->assertDispatched('success'); + + $this->application->refresh(); + $domains = json_decode($this->application->docker_compose_domains, true); + + expect(data_get($domains, 'web.redirect'))->toBe('www') + ->and(data_get($domains, 'web.domain'))->toContain('https://www.web.example.com') + ->and(data_get($domains, 'api.redirect'))->toBe('both') + ->and(data_get($domains, 'api.domain'))->toBe('https://api.example.com,https://www.api.example.com'); +}); + +it('auto-adds missing www pair for a single compose service redirect', 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']) + ->set('serviceRedirects.web', 'www') + ->call('setServiceRedirect', 'web') + ->assertDispatched('success'); + + $this->application->refresh(); + $domains = json_decode($this->application->docker_compose_domains, true); + $webDomains = explode(',', (string) data_get($domains, 'web.domain')); + + expect(data_get($domains, 'web.redirect'))->toBe('www') + ->and($webDomains)->toContain('https://web.example.com') + ->and($webDomains)->toContain('https://www.web.example.com'); +}); + +it('uses compose service redirect for suggested domain messaging', 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' => 'www'], + ]), + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->set('isCompose', true) + ->set('composeServices', ['web']) + ->set('serviceRedirects.web', 'www'); + + $component->instance()->domainRows = (function () use ($component) { + $method = new ReflectionMethod($component->instance(), 'buildDomainRows'); + + return $method->invoke($component->instance()); + })(); + + $suggested = collect($component->get('domainRows'))->firstWhere('is_suggested', true); + + expect($suggested)->not->toBeNull() + ->and($suggested['suggestion_role'] ?? null)->toBe('canonical') + ->and($suggested['url'] ?? null)->toBe('https://www.web.example.com'); +}); diff --git a/tests/Feature/ApplicationRedirectTest.php b/tests/Feature/ApplicationRedirectTest.php index 90b9ddd679..ae7ffe4a97 100644 --- a/tests/Feature/ApplicationRedirectTest.php +++ b/tests/Feature/ApplicationRedirectTest.php @@ -85,7 +85,7 @@ describe('Application Redirect', function () { expect($application->redirect)->toBe('www'); }); - test('setRedirect rejects www redirect when no www domain exists', function () { + test('setRedirect auto-adds missing www domain instead of rejecting', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, @@ -98,13 +98,16 @@ describe('Application Redirect', function () { ->assertSuccessful() ->set('redirect', 'www') ->call('setRedirect') - ->assertDispatched('error'); + ->assertDispatched('success'); $application->refresh(); - expect($application->redirect)->toBe('both'); + expect($application->redirect)->toBe('www') + ->and(explode(',', (string) $application->fqdn)) + ->toContain('https://example.com') + ->toContain('https://www.example.com'); }); - test('setRedirect only classifies domains whose hostname starts with www', function (string $fqdn) { + test('setRedirect only treats hostname-leading www as www and auto-adds the real pair', function (string $fqdn, string $expectedWww) { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, @@ -117,13 +120,16 @@ describe('Application Redirect', function () { ->assertSuccessful() ->set('redirect', 'www') ->call('setRedirect') - ->assertDispatched('error'); + ->assertDispatched('success'); $application->refresh(); - expect($application->redirect)->toBe('both'); + expect($application->redirect)->toBe('www') + ->and(explode(',', (string) $application->fqdn)) + ->toContain($fqdn) + ->toContain($expectedWww); })->with([ - 'www in path' => 'https://example.com/www.example.com', - 'www in unrelated hostname label' => 'https://app.www.example.com', + 'www in path' => ['https://example.com/www.example.com', 'https://www.example.com/www.example.com'], + 'www in unrelated hostname label' => ['https://app.www.example.com', 'https://www.app.www.example.com'], ]); }); diff --git a/tests/Feature/Security/CommandInjectionSecurityTest.php b/tests/Feature/Security/CommandInjectionSecurityTest.php index 42c08c29d4..85ba2faf44 100644 --- a/tests/Feature/Security/CommandInjectionSecurityTest.php +++ b/tests/Feature/Security/CommandInjectionSecurityTest.php @@ -176,9 +176,10 @@ describe('API validation rules for path fields', function () { test('docker compose service domains validation rejects command injection payloads', function () { $rules = [ 'docker_compose_domains' => 'array|nullable', - 'docker_compose_domains.*' => 'array:name,domain', + 'docker_compose_domains.*' => 'array:name,domain,redirect', 'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(), + 'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both', ]; $validator = validator( diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php index f336bbed5b..08323daa59 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -98,6 +98,52 @@ it('lists domains grouped by service application on the stack domains page', fun ->assertSee('Web'); }); +it('does not persist service redirect until Set Direction is called', function () { + $this->webApp->update(['fqdn' => 'https://web.example.com', 'redirect' => 'both']); + + Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + ->set("serviceRedirects.{$this->webApp->id}", 'www'); + + expect($this->webApp->fresh()->redirect)->toBe('both') + ->and($this->webApp->fresh()->fqdn)->toBe('https://web.example.com'); +}); + +it('sets redirect direction per service application without changing other apps', function () { + $this->webApp->update(['fqdn' => 'https://web.example.com', 'redirect' => 'both']); + $this->apiApp->update(['redirect' => 'both']); + + Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + ->set("serviceRedirects.{$this->webApp->id}", 'www') + ->call('setServiceRedirect', $this->webApp->id) + ->assertDispatched('success'); + + expect($this->webApp->fresh()->redirect)->toBe('www') + ->and(explode(',', (string) $this->webApp->fresh()->fqdn)) + ->toContain('https://web.example.com') + ->toContain('https://www.web.example.com') + ->and($this->apiApp->fresh()->redirect)->toBe('both') + ->and($this->apiApp->fresh()->fqdn)->toBe('https://api.example.com'); +}); + +it('auto-adds missing non-www pair for a service application redirect', function () { + $this->apiApp->update([ + 'fqdn' => 'https://www.api.example.com', + 'redirect' => 'both', + ]); + + Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + ->set("serviceRedirects.{$this->apiApp->id}", 'non-www') + ->call('setServiceRedirect', $this->apiApp->id) + ->assertDispatched('success'); + + $this->apiApp->refresh(); + + expect($this->apiApp->redirect)->toBe('non-www') + ->and(explode(',', (string) $this->apiApp->fqdn)) + ->toContain('https://www.api.example.com') + ->toContain('https://api.example.com'); +}); + it('adds a domain to a selected service application', function () { Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) ->set('newServiceApplicationId', $this->webApp->id)