feat(domains): add per-service redirect controls

Store www/non-www redirect preferences for compose and service apps.
This commit is contained in:
Andras Bacsai
2026-07-29 17:57:06 +02:00
parent 43b4ac10cb
commit 35339a09aa
14 changed files with 1109 additions and 109 deletions
@@ -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');
+316 -53
View File
@@ -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<string, string>
*/
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<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}>
*/
@@ -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.<br><br>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.<br><br>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<int, string> $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).<br><br>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;
+257 -8
View File
@@ -19,6 +19,16 @@ class Domains extends Component
/** @var array<int, array{id: int, name: string, image: ?string, required_port: ?int}> */
public array $serviceApps = [];
/**
* Per service-application www/non-www redirect direction.
*
* @var array<int|string, string>
*/
public array $serviceRedirects = [];
/** Service application id when a pending domain conflict belongs to setServiceRedirect. */
public ?int $pendingRedirectServiceApplicationId = null;
/** @var array<int, array<string, mixed>> */
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<int, array<string, mixed>>
*/
@@ -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<int, string> $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).<br><br>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;
}
+1
View File
@@ -17,6 +17,7 @@ class ServiceApplication extends BaseModel
'human_name',
'description',
'fqdn',
'redirect',
'domain_dns_statuses',
'ports',
'exposes',
+23 -8
View File
@@ -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,
));
}
}
+19 -4
View File
@@ -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,
)
);
}
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('service_applications', function (Blueprint $table) {
$table->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');
});
}
};
+2
View File
@@ -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": {
@@ -98,8 +98,8 @@
@endif
@else
<div class="flex-1 min-w-0">
<x-forms.select label="Direction" id="redirect" wire:model.live="redirect" required
helper="Add <strong>both</strong> www and non-www in Coolify. Both hostnames must resolve to this server so the proxy can serve or redirect them. Do not use a DNS-provider URL redirect record for the non-canonical host; Coolify handles the HTTP redirect.">
<x-forms.select label="Direction" id="redirect" wire:model="redirect" required
helper="Add <strong>both</strong> www and non-www in Coolify. Both hostnames must resolve to this server so the proxy can serve or redirect them. Do not use a DNS-provider URL redirect record for the non-canonical host; Coolify handles the HTTP redirect. Changes apply when you click Set Direction.">
<option value="both">Allow www & non-www.</option>
<option value="www">Redirect to www.</option>
<option value="non-www">Redirect to non-www.</option>
@@ -177,6 +177,37 @@
</span>
</div>
@unless ($labelsAreWritable)
@php
$redirectWireKey = $this->serviceRedirectWireKey($serviceName);
@endphp
<div class="flex flex-col gap-2 md:flex-row md:items-end md:max-w-xl">
<div class="flex-1 min-w-0">
<x-forms.select label="Direction" id="serviceRedirects.{{ $redirectWireKey }}"
wire:model="serviceRedirects.{{ $redirectWireKey }}" required
helper="Per-service www/non-www redirect. Add <strong>both</strong> hosts for this service when using a redirect. Both hostnames must resolve to this server so the proxy can serve or redirect them. Changes apply when you click Set Direction.">
<option value="both">Allow www & non-www.</option>
<option value="www">Redirect to www.</option>
<option value="non-www">Redirect to non-www.</option>
</x-forms.select>
</div>
@can('update', $application)
{{-- Single-quoted attr so Js::from double-quotes don't break HTML. --}}
<x-modal-confirmation title="Confirm Redirection Setting?" buttonTitle="Set Direction"
submitAction='setServiceRedirect({{ \Illuminate\Support\Js::from($serviceName) }})'
:actions="['Traffic for this service will be redirected to the selected direction. Missing www/non-www counterparts will be added automatically when possible.']"
confirmationText="{{ $serviceName }}/"
confirmationLabel="Please confirm by entering the service name below"
shortConfirmationLabel="Service name" :confirmWithPassword="false"
step2ButtonText="Set Direction" canGate="update" :canResource="$application">
<x-slot:customButton>
<div class="w-[7.2rem]">Set Direction</div>
</x-slot:customButton>
</x-modal-confirmation>
@endcan
</div>
@endunless
@if ($rows->isEmpty())
<div
class="p-4 text-sm border border-dashed rounded-sm dark:border-coolgray-300 dark:text-neutral-400">
@@ -131,6 +131,32 @@
</span>
</div>
<div class="flex flex-col gap-2 md:flex-row md:items-end md:max-w-xl">
<div class="flex-1 min-w-0">
<x-forms.select canGate="update" :canResource="$service" label="Direction"
id="serviceRedirects.{{ $appId }}" wire:model="serviceRedirects.{{ $appId }}"
required
helper="Per-service www/non-www redirect. Add <strong>both</strong> hosts for this service when using a redirect. Both hostnames must resolve to this server so the proxy can serve or redirect them. Changes apply when you click Set Direction.">
<option value="both">Allow www & non-www.</option>
<option value="www">Redirect to www.</option>
<option value="non-www">Redirect to non-www.</option>
</x-forms.select>
</div>
@can('update', $service)
<x-modal-confirmation title="Confirm Redirection Setting?" buttonTitle="Set Direction"
submitAction="setServiceRedirect({{ (int) $appId }})"
:actions="['Traffic for this service will be redirected to the selected direction.']"
confirmationText="{{ ($heading ?: 'service') . '/' }}"
confirmationLabel="Please confirm by entering the service name below"
shortConfirmationLabel="Service name" :confirmWithPassword="false"
step2ButtonText="Set Direction" canGate="update" :canResource="$service">
<x-slot:customButton>
<div class="w-[7.2rem]">Set Direction</div>
</x-slot:customButton>
</x-modal-confirmation>
@endcan
</div>
@foreach ($rows as $row)
@php
$index = collect($domainRows)->search(
+302 -13
View File
@@ -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');
});
+14 -8
View File
@@ -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'],
]);
});
@@ -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(
+46
View File
@@ -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)