mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 02:24:11 -05:00
feat(domains): add per-service redirect controls
Store www/non-www redirect preferences for compose and service apps.
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ class ServiceApplication extends BaseModel
|
||||
'human_name',
|
||||
'description',
|
||||
'fqdn',
|
||||
'redirect',
|
||||
'domain_dns_statuses',
|
||||
'ports',
|
||||
'exposes',
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user