2026-08-03 21:11:54 +00:00
< ? php
namespace App\Livewire\Project\Application ;
2026-08-21 19:22:04 +00:00
use App\Actions\Shared\CheckDomainDns ;
use App\Jobs\CheckDomainDnsJob ;
2026-08-03 21:11:54 +00:00
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect ;
2026-08-10 13:10:30 +00:00
use App\Livewire\Project\Shared\ConfigurationChecker ;
2026-08-03 21:11:54 +00:00
use App\Models\Application ;
use App\Models\Server ;
2026-09-02 17:39:19 +00:00
use App\Support\DomainPortOverrides ;
2026-08-17 14:05:21 +00:00
use App\Support\DomainUrlParts ;
2026-08-03 21:11:54 +00:00
use App\Support\ValidationPatterns ;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests ;
use Illuminate\Support\Collection ;
2026-08-21 19:22:04 +00:00
use Illuminate\Support\Facades\DB ;
2026-08-03 21:11:54 +00:00
use Livewire\Component ;
class Domains extends Component
{
use AuthorizesRequests ;
use InteractsWithCloudflareDomainConnect ;
2026-08-10 13:15:48 +00:00
protected bool $notifyRedirectUpdate = true ;
2026-08-03 21:11:54 +00:00
public Application $application ;
public string $redirect = 'both' ;
2026-08-18 07:08:18 +00:00
public bool $isForceHttpsEnabled = true ;
2026-08-03 21:11:54 +00:00
/**
* 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 = '' ;
2026-08-17 14:05:21 +00:00
public array $newDomainParts = [ 'scheme' => 'https' , 'host' => '' , 'port' => '' , 'path' => '' ];
public bool $newDomainPartsChanged = false ;
2026-08-03 21:11:54 +00:00
public ? string $newDomainService = null ;
public ? int $editingIndex = null ;
public string $editingDomain = '' ;
2026-08-17 14:05:21 +00:00
public array $editingDomainParts = [ 'scheme' => 'https' , 'host' => '' , 'port' => '' , 'path' => '' ];
public bool $editingDomainPartsChanged = false ;
2026-08-03 21:11:54 +00:00
public ? string $editingService = null ;
2026-09-10 12:00:50 +00:00
public string $editingIndexing = 'index' ;
public string $editingRedirect = 'both' ;
public string $editingOriginalRedirect = 'both' ;
public bool $editingDomainWasRegenerated = false ;
public ? string $editingGeneratedHost = null ;
2026-09-02 17:39:19 +00:00
/** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool, internal_port?: ?int, has_port_override?: bool}> */
2026-08-03 21:11:54 +00:00
public array $domainRows = [];
/** When set, the next addSuggestedDomain call for this index skips the DNS block. */
public ? int $forceAddSuggestedIndex = null ;
/** @var array<int, string> */
public array $composeServices = [];
public array $domainConflicts = [];
public bool $showDomainConflictModal = false ;
2026-09-02 17:39:19 +00:00
public bool $showPortWarningModal = false ;
public bool $forceUseUnknownPort = false ;
public ? int $unrecognizedPort = null ;
public ? string $pendingPortAction = null ;
2026-08-03 21:11:54 +00:00
public bool $forceSaveDomains = false ;
public bool $forceSaveDns = false ;
public bool $addDomainDnsFailed = false ;
public string $addDomainDnsMessage = '' ;
public bool $showEditDomainModal = false ;
public bool $forceSaveEditDns = false ;
public bool $editDomainDnsFailed = false ;
public string $editDomainDnsMessage = '' ;
/** Pending save path after conflict confirmation: add | update | suggested | redirect */
public ? string $pendingAction = null ;
public bool $isCompose = false ;
public bool $labelsAreWritable = false ;
public bool $isCheckingDns = false ;
public bool $dnsValidationEnabled = true ;
/** Resolved or literal IP users should point DNS at. */
public ? string $serverIp = null ;
/** Raw server IP/hostname as configured (may be a hostname). */
public ? string $serverIpConfigured = null ;
protected $listeners = [
'configurationChanged' => 'refreshDomains' ,
'confirmDomainUsage' ,
];
protected function rules () : array
{
return [
'newDomain' => ValidationPatterns :: applicationDomainRules (),
'editingDomain' => ValidationPatterns :: applicationDomainRules (),
2026-09-10 12:00:50 +00:00
'editingIndexing' => 'string|required|in:index,noindex' ,
'editingRedirect' => 'string|required|in:both,www,non-www' ,
2026-08-03 21:11:54 +00:00
'redirect' => 'string|required|in:both,www,non-www' ,
2026-08-18 07:08:18 +00:00
'isForceHttpsEnabled' => 'boolean' ,
2026-08-03 21:11:54 +00:00
'serviceRedirects' => 'array' ,
'serviceRedirects.*' => 'string|in:both,www,non-www' ,
];
}
protected function messages () : array
{
return array_merge (
ValidationPatterns :: combinedMessages (),
[
'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.' ,
]
);
}
public function mount () : void
{
$this -> authorize ( 'view' , $this -> application );
$this -> loadDomainState ();
}
public function refreshDomains () : void
{
2026-09-07 16:29:58 +00:00
$editingRow = $this -> editingIndex !== null ? ( $this -> domainRows [ $this -> editingIndex ] ? ? null ) : null ;
2026-08-03 21:11:54 +00:00
$this -> loadDomainState ();
2026-09-07 16:29:58 +00:00
if ( $editingRow !== null ) {
$index = collect ( $this -> domainRows ) -> search ( fn ( array $row ) : bool => $row [ 'url' ] === $editingRow [ 'url' ]
&& ( $row [ 'service' ] ? ? null ) === ( $editingRow [ 'service' ] ? ? null ));
$this -> editingIndex = $index === false ? null : ( int ) $index ;
}
2026-08-03 21:11:54 +00:00
}
2026-08-21 19:22:04 +00:00
public function pollDnsChecks () : void
{
$this -> authorize ( 'view' , $this -> application );
$checkingRows = collect ( $this -> domainRows )
-> where ( 'dns_status' , 'checking' )
-> values ();
$this -> refreshDomains ();
foreach ( $checkingRows as $checkingRow ) {
$row = collect ( $this -> domainRows ) -> first ( fn ( array $row ) : bool => $row [ 'url' ] === $checkingRow [ 'url' ]
&& ( $row [ 'service' ] ? ? null ) === ( $checkingRow [ 'service' ] ? ? null ));
if ( ! is_array ( $row ) || $row [ 'dns_status' ] === 'checking' ) {
continue ;
}
$this -> dispatchDnsCheckNotification ( $row [ 'url' ], $row [ 'dns_status' ]);
}
}
protected function dispatchDnsCheckNotification ( string $url , string $status ) : void
{
$host = parse_url ( $url , PHP_URL_HOST ) ? : $url ;
match ( $status ) {
'ok' => $this -> dispatch ( 'success' , " DNS is configured correctly for { $host } . " ),
'failed' => $this -> dispatch ( 'error' , " DNS is not configured for { $host } . Review the required DNS record. " ),
default => $this -> dispatch ( 'info' , " DNS check skipped for { $host } . " ),
};
}
2026-08-10 13:10:30 +00:00
public function toggleNoindexDomain ( string $domain , string | bool $indexing ) : void
{
$this -> authorize ( 'update' , $this -> application );
$noindex = $indexing === true || $indexing === 'noindex' ;
$domains = $this -> application -> noindexDomains ();
$domains = $noindex ? $domains -> push ( $domain ) : $domains -> reject ( fn ( string $item ) => $item === $domain );
$this -> application -> setNoindexDomains ( $domains );
$this -> application -> save ();
$this -> application -> refresh ();
$this -> resetDefaultLabels ();
$this -> dispatch ( 'configurationChanged' ) -> to ( ConfigurationChecker :: class );
$this -> dispatch ( 'success' , 'Search engine indexing updated.' );
}
2026-08-10 22:06:55 +00:00
public function updateRedirect ( string $redirect ) : void
{
$this -> redirect = $redirect ;
$this -> setRedirect ();
}
2026-08-18 07:08:18 +00:00
public function updateForceHttps () : void
{
$this -> authorize ( 'update' , $this -> application );
$this -> validateOnly ( 'isForceHttpsEnabled' );
$this -> application -> settings -> is_force_https_enabled = $this -> isForceHttpsEnabled ;
$this -> application -> settings -> save ();
$this -> resetDefaultLabels ();
$this -> dispatch ( 'configurationChanged' ) -> to ( ConfigurationChecker :: class );
$this -> dispatch ( 'success' , 'HTTP to HTTPS redirect updated.' );
}
2026-08-03 21:11:54 +00:00
public function loadDomainState () : void
{
$this -> application -> refresh ();
$this -> application -> loadMissing ([ 'destination.server' , 'settings' , 'additional_servers' ]);
$this -> isCompose = $this -> application -> build_pack === 'dockercompose' ;
$this -> labelsAreWritable = $this -> application -> settings -> is_container_label_readonly_enabled === false ;
2026-09-07 16:29:58 +00:00
if ( $this -> pendingAction !== 'redirect' || $this -> isCompose ) {
$this -> redirect = $this -> application -> redirect ? ? 'both' ;
}
2026-08-18 07:08:18 +00:00
$this -> isForceHttpsEnabled = $this -> application -> isForceHttpsEnabled ();
2026-08-03 21:11:54 +00:00
$settings = instanceSettings ();
$this -> dnsValidationEnabled = ( bool ) data_get ( $settings , 'is_dns_validation_enabled' , true );
$server = $this -> application -> destination ? -> server ;
if ( $server ) {
if ( $server -> id === 0 ) {
$configured = data_get ( $settings , 'public_ipv4' )
? : data_get ( $settings , 'public_ipv6' )
? : $server -> ip ;
} else {
$configured = $server -> ip ;
}
$resolved = resolveServerIpAddress ( is_string ( $configured ) ? $configured : null );
$this -> serverIpConfigured = $resolved [ 'configured' ];
// Prefer real IP so users know what DNS record to set even when the
// server address is stored as a hostname (e.g. coolify-testing-host).
$this -> serverIp = $resolved [ 'ip' ] ? ? $resolved [ 'configured' ];
} else {
$this -> serverIp = null ;
$this -> serverIpConfigured = null ;
}
$this -> composeServices = [];
2026-09-07 16:29:58 +00:00
$pendingRedirect = $this -> pendingRedirectService !== null
? ( $this -> serviceRedirects [ $this -> serviceRedirectWireKey ( $this -> pendingRedirectService )] ? ? null )
: null ;
2026-08-03 21:11:54 +00:00
$this -> serviceRedirects = [];
if ( $this -> isCompose ) {
try {
$parsed = $this -> application -> parse () ? ? [];
} catch ( \Throwable ) {
$parsed = [];
}
$services = data_get ( $parsed , 'services' , []);
foreach ( $services as $serviceName => $service ) {
if ( ! isDatabaseImage ( data_get ( $service , 'image' ))) {
$this -> composeServices [] = $serviceName ;
}
}
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 (
2026-09-07 16:29:58 +00:00
$this -> pendingAction === 'redirect' && $serviceName === $this -> pendingRedirectService
? $pendingRedirect
: ( is_string ( $storedRedirect ) ? $storedRedirect : null )
2026-08-03 21:11:54 +00:00
);
}
}
2026-08-05 11:50:11 +00:00
// Do not auto-promote www/non-www pairs here: load/refresh also runs after
// removeDomain, and re-adding counterparts would undo intentional deletes.
// Pairs are still ensured on setRedirect, addDomain, and generateDomain.
2026-08-03 21:11:54 +00:00
$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 } >
*/
protected function buildDomainRows () : array
{
$rows = [];
$stored = $this -> storedDomainDnsStatuses ();
if ( $this -> isCompose ) {
$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 ) {
$configured = [];
// 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 ;
$configured [] = $row ;
}
}
2026-08-05 11:50:11 +00:00
return $this -> sortDomainRowsByDnsStatus ( $rows );
2026-08-03 21:11:54 +00:00
}
foreach ( $this -> splitDomains ( $this -> application -> fqdn ) as $url ) {
$rows [] = $this -> domainRowFromStored ( $url , null , $stored );
}
2026-08-13 06:58:10 +00:00
return $this -> sortDomainRowsByDnsStatus ( $rows );
2026-08-05 11:50:11 +00:00
}
/**
* @ param array < int , array < string , mixed >> $rows
* @ return array < int , array < string , mixed >>
*/
protected function sortDomainRowsByDnsStatus ( array $rows ) : array
{
return collect ( $rows )
-> sortBy ( fn ( array $row ) : int => ( $row [ 'dns_status' ] ? ? null ) === 'failed' ? 0 : 1 )
-> values ()
-> all ();
2026-08-03 21:11:54 +00:00
}
/**
* Missing www / non - www counterparts as full domain rows ( DNS - checkable , not yet saved ) .
*
* Messaging depends on Direction :
* - both : both hosts are first - class and need DNS to the server
* - www / non - www : the missing pair is for Coolify ' s redirect ( still must resolve to
* the server so the proxy can redirect ; not framed as a separate primary site )
*
* @ param array < int , array < string , mixed >> $configuredRows
* @ param array < string , array { status ? : string , message ? : string , expected_ip ? : ? string , checked_at ? : ? string } > $stored
* @ 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 , suggestion_role : ? string } >
*/
protected function buildSuggestedWwwRows ( array $configuredRows , array $stored , ? string $service = null ) : array
{
$knownHosts = [];
foreach ( $configuredRows as $row ) {
$host = $this -> domainHost (( string ) ( $row [ 'url' ] ? ? '' ));
if ( $host !== null ) {
$knownHosts [ strtolower ( $host )] = true ;
}
}
$suggested = [];
foreach ( $configuredRows as $row ) {
$url = ( string ) ( $row [ 'url' ] ? ? '' );
$rowService = $service ? ? ( $row [ 'service' ] ? ? null );
$counterpart = $this -> wwwCounterpartUrl ( $url );
if ( $counterpart === null ) {
continue ;
}
$counterpartHost = $this -> domainHost ( $counterpart );
if ( $counterpartHost === null ) {
continue ;
}
$hostKey = strtolower ( $counterpartHost );
if ( isset ( $knownHosts [ $hostKey ])) {
continue ;
}
$knownHosts [ $hostKey ] = true ;
$base = $this -> domainRowFromStored ( $counterpart , $rowService , $stored );
$isWww = str_starts_with ( $hostKey , 'www.' );
$meta = $this -> suggestedDomainMeta ( $isWww , $this -> serviceRedirectFor ( is_string ( $rowService ) ? $rowService : null ));
$base [ 'is_suggested' ] = true ;
$base [ 'suggested_for' ] = $url ;
2026-08-05 11:50:11 +00:00
$base [ 'suggestion_label' ] = null ;
2026-08-03 21:11:54 +00:00
$base [ 'suggestion_role' ] = $meta [ 'role' ];
$base [ 'needs_force_add' ] = false ;
2026-08-05 11:50:11 +00:00
$base [ 'dns_message' ] = $meta [ 'pending_message' ];
2026-08-03 21:11:54 +00:00
$suggested [] = $base ;
}
return $suggested ;
}
/**
* 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
{
2026-08-05 11:50:11 +00:00
$pendingMessage = 'Not configured yet.' ;
2026-08-03 21:11:54 +00:00
$redirect = $redirectOverride ? ? ( $this -> redirect ? : 'both' );
return match ( $redirect ) {
'www' => $suggestedIsWww
? [
2026-08-05 11:50:11 +00:00
'label' => 'Not added · canonical www' ,
2026-08-03 21:11:54 +00:00
'role' => 'canonical' ,
2026-08-05 11:50:11 +00:00
'pending_message' => $pendingMessage ,
'dns_suffix' => '' ,
2026-08-03 21:11:54 +00:00
]
: [
2026-08-05 11:50:11 +00:00
'label' => 'Not added · redirect source' ,
2026-08-03 21:11:54 +00:00
'role' => 'redirect_source' ,
2026-08-05 11:50:11 +00:00
'pending_message' => $pendingMessage ,
'dns_suffix' => '' ,
2026-08-03 21:11:54 +00:00
],
'non-www' => $suggestedIsWww
? [
2026-08-05 11:50:11 +00:00
'label' => 'Not added · redirect source' ,
2026-08-03 21:11:54 +00:00
'role' => 'redirect_source' ,
2026-08-05 11:50:11 +00:00
'pending_message' => $pendingMessage ,
'dns_suffix' => '' ,
2026-08-03 21:11:54 +00:00
]
: [
2026-08-05 11:50:11 +00:00
'label' => 'Not added · canonical non-www' ,
2026-08-03 21:11:54 +00:00
'role' => 'canonical' ,
2026-08-05 11:50:11 +00:00
'pending_message' => $pendingMessage ,
'dns_suffix' => '' ,
2026-08-03 21:11:54 +00:00
],
default => [
2026-08-05 11:50:11 +00:00
'label' => $suggestedIsWww ? 'Not added · www' : 'Not added · non-www' ,
2026-08-03 21:11:54 +00:00
'role' => 'pair' ,
2026-08-05 11:50:11 +00:00
'pending_message' => $pendingMessage ,
2026-08-03 21:11:54 +00:00
'dns_suffix' => '' ,
],
};
}
/**
* @ param array < string , array { status ? : string , message ? : string , expected_ip ? : ? string , checked_at ? : ? string } > $stored
2026-09-02 17:39:19 +00:00
* @ return 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 , internal_port : ? int , has_port_override : bool }
2026-08-03 21:11:54 +00:00
*/
protected function domainRowFromStored ( string $url , ? string $service , array $stored ) : array
{
$key = $this -> domainDnsStatusKey ( $url , $service );
$entry = $stored [ $key ] ? ? null ;
2026-09-03 18:38:19 +00:00
$port = $this -> effectiveDomainInternalPort ( $url , $service );
2026-08-03 21:11:54 +00:00
2026-09-02 17:39:19 +00:00
$row = [
2026-08-03 21:11:54 +00:00
'url' => $url ,
'service' => $service ,
2026-09-02 17:39:19 +00:00
'internal_port' => $port [ 'internal_port' ],
'has_port_override' => $port [ 'has_port_override' ],
2026-08-03 21:11:54 +00:00
'dns_status' => 'pending' ,
'dns_message' => 'Not checked yet.' ,
'expected_ip' => $this -> serverIp ,
'checked_at' => null ,
2026-08-21 19:22:04 +00:00
'check_id' => null ,
2026-08-03 21:11:54 +00:00
'is_suggested' => false ,
'suggested_for' => null ,
'suggestion_label' => null ,
'needs_force_add' => false ,
];
2026-09-02 17:39:19 +00:00
if ( is_array ( $entry ) && filled ( data_get ( $entry , 'status' ))) {
$row [ 'dns_status' ] = ( string ) data_get ( $entry , 'status' , 'pending' );
$row [ 'dns_message' ] = ( string ) data_get ( $entry , 'message' , 'Not checked yet.' );
$row [ 'expected_ip' ] = data_get ( $entry , 'expected_ip' ) ? : $this -> serverIp ;
$row [ 'checked_at' ] = data_get ( $entry , 'checked_at' );
$row [ 'check_id' ] = data_get ( $entry , 'check_id' );
}
return $row ;
}
/**
* @ return array { internal_port : ? int , has_port_override : bool }
*/
2026-09-03 18:38:19 +00:00
protected function effectiveDomainInternalPort ( string $url , ? string $service = null ) : array
2026-09-02 17:39:19 +00:00
{
$canonical = DomainPortOverrides :: withoutPort ( $url );
$overrides = $this -> application -> domain_port_overrides ? ? [];
$legacyPortPart = DomainUrlParts :: split ( $url )[ 'port' ] ? ? '' ;
$legacyPort = $legacyPortPart !== '' ? ( int ) $legacyPortPart : null ;
$hasMapEntry = array_key_exists ( $canonical , $overrides );
if ( $hasMapEntry ) {
return [
'internal_port' => ( int ) $overrides [ $canonical ],
'has_port_override' => true ,
];
}
if ( $legacyPort !== null ) {
return [
'internal_port' => $legacyPort ,
'has_port_override' => true ,
];
}
2026-09-07 13:17:23 +00:00
$composePort = dockerComposeServicePort ( $this -> application -> docker_compose_raw , $service );
if ( $composePort !== null ) {
2026-09-02 17:39:19 +00:00
return [
2026-09-07 13:17:23 +00:00
'internal_port' => $composePort ,
2026-09-02 17:39:19 +00:00
'has_port_override' => false ,
];
}
2026-09-07 13:17:23 +00:00
if ( $this -> isCompose && $service !== null ) {
2026-09-03 18:38:19 +00:00
return [
2026-09-07 13:17:23 +00:00
'internal_port' => null ,
2026-09-03 18:38:19 +00:00
'has_port_override' => false ,
];
}
2026-09-07 13:17:23 +00:00
if ( $this -> application -> settings ? -> is_static ) {
2026-09-05 12:19:38 +00:00
return [
2026-09-07 13:17:23 +00:00
'internal_port' => 80 ,
2026-09-05 12:19:38 +00:00
'has_port_override' => false ,
];
}
2026-09-02 17:39:19 +00:00
$exposed = $this -> application -> ports_exposes_array ;
$defaultPort = isset ( $exposed [ 0 ]) && is_numeric ( $exposed [ 0 ]) && ( int ) $exposed [ 0 ] > 0
? ( int ) $exposed [ 0 ]
: null ;
return [
'internal_port' => $defaultPort ,
'has_port_override' => false ,
];
}
/**
* @ param array { scheme : string , host : string , port : string , path : string } $parts
*/
protected function portFromParts ( array $parts ) : ? int
{
$port = trim (( string ) ( $parts [ 'port' ] ? ? '' ));
if ( $port === '' || ! ctype_digit ( $port ) || ( int ) $port <= 0 ) {
return null ;
}
return ( int ) $port ;
}
protected function currentRowPort ( string $url ) : ? int
{
$canonical = DomainPortOverrides :: withoutPort ( $url );
$override = ( $this -> application -> domain_port_overrides ? ? [])[ $canonical ] ? ? null ;
if ( filled ( $override ) && ( int ) $override > 0 ) {
return ( int ) $override ;
}
$legacy = DomainUrlParts :: split ( $url )[ 'port' ] ? ? '' ;
return $legacy !== '' && ctype_digit ( $legacy ) ? ( int ) $legacy : null ;
}
2026-09-07 13:17:23 +00:00
protected function shouldConfirmPort ( ? int $port , ? int $currentPort = null , ? string $serviceName = null ) : bool
2026-09-02 17:39:19 +00:00
{
if ( $this -> forceUseUnknownPort || $port === null ) {
return false ;
}
if ( $currentPort !== null && $port === $currentPort ) {
return false ;
}
2026-09-07 13:17:23 +00:00
return $this -> application -> portRequiresConfirmation ( $port , $serviceName );
2026-09-02 17:39:19 +00:00
}
protected function openPortWarning ( ? int $port , string $action ) : void
{
$this -> unrecognizedPort = $port ;
$this -> pendingPortAction = $action ;
$this -> showPortWarningModal = true ;
2026-08-03 21:11:54 +00:00
}
/**
* @ return array < string , array { status : string , message : string , expected_ip : ? string , checked_at : ? string } >
*/
protected function storedDomainDnsStatuses () : array
{
$stored = $this -> application -> domain_dns_statuses ;
return is_array ( $stored ) ? $stored : [];
}
protected function domainDnsStatusKey ( string $url , ? string $service = null ) : string
{
return $service ? " { $service } | { $url } " : $url ;
}
/**
* @ return array < int , string >
*/
protected function splitDomains ( ? string $value ) : array
{
if ( blank ( $value )) {
return [];
}
return collect ( explode ( ',' , $value ))
-> map ( fn ( $domain ) => trim (( string ) $domain ))
-> filter ()
-> values ()
-> all ();
}
protected function authorizeUpdateForDomainConnect () : void
{
$this -> authorize ( 'update' , $this -> application );
}
2026-09-04 18:56:08 +00:00
protected function usesInstanceNetworkAddressesForDnsHints () : bool
{
return $this -> application -> destination ? -> server ? -> id === 0 ;
}
2026-08-03 21:11:54 +00:00
public function checkAllDns () : void
{
$this -> authorize ( 'update' , $this -> application );
2026-09-10 12:00:50 +00:00
foreach ( $this -> domainRows as $row ) {
$this -> queueUrlsDns ([ $row [ 'url' ]], $row [ 'service' ] ? ? null );
2026-08-03 21:11:54 +00:00
}
}
public function checkDomainDns ( int $index ) : void
{
$this -> authorize ( 'update' , $this -> application );
if ( ! isset ( $this -> domainRows [ $index ])) {
return ;
}
2026-09-10 12:00:50 +00:00
$row = $this -> domainRows [ $index ];
$this -> queueUrlsDns ([ $row [ 'url' ]], $row [ 'service' ] ? ? null );
2026-08-03 21:11:54 +00:00
}
2026-08-21 19:22:04 +00:00
protected function applyDnsStatus ( int $index , Server $server ) : void
2026-08-03 21:11:54 +00:00
{
2026-08-21 19:22:04 +00:00
$this -> applyDnsStatuses ([ $index ], $server );
}
2026-08-03 21:11:54 +00:00
2026-08-21 19:22:04 +00:00
/**
* @ param array < int , int > $indexes
*/
protected function applyDnsStatuses ( array $indexes , Server $server ) : void
{
$entries = [];
2026-08-03 21:11:54 +00:00
2026-08-21 19:22:04 +00:00
foreach ( $indexes as $index ) {
$entries [( string ) $index ] = $this -> domainRows [ $index ][ 'url' ];
2026-08-03 21:11:54 +00:00
}
2026-08-21 19:22:04 +00:00
$results = CheckDomainDns :: run ( $entries , $server , $this -> serverIp );
foreach ( $results as $index => $result ) {
$index = ( int ) $index ;
$this -> domainRows [ $index ][ 'dns_status' ] = $result [ 'status' ];
$this -> domainRows [ $index ][ 'dns_message' ] = $result [ 'message' ];
// Keep suggested-row copy short after DNS checks (no role badge).
if ( $this -> domainRows [ $index ][ 'is_suggested' ] ? ? false ) {
$isWww = str_starts_with ( strtolower (( string ) $this -> domainHost (( string ) $this -> domainRows [ $index ][ 'url' ])), 'www.' );
$serviceName = $this -> domainRows [ $index ][ 'service' ] ? ? null ;
$meta = $this -> suggestedDomainMeta (
$isWww ,
$this -> serviceRedirectFor ( is_string ( $serviceName ) ? $serviceName : null )
);
$this -> domainRows [ $index ][ 'dns_message' ] = $meta [ 'pending_message' ];
$this -> domainRows [ $index ][ 'suggestion_label' ] = null ;
$this -> domainRows [ $index ][ 'suggestion_role' ] = $meta [ 'role' ];
}
$this -> domainRows [ $index ][ 'expected_ip' ] = $result [ 'expected_ip' ];
$this -> domainRows [ $index ][ 'checked_at' ] = $result [ 'checked_at' ];
$this -> domainRows [ $index ][ 'check_id' ] = null ;
}
2026-08-03 21:11:54 +00:00
}
/**
* Persist current domain row DNS results and drop statuses for removed domains .
*/
protected function persistDomainDnsStatuses () : void
{
$statuses = [];
foreach ( $this -> domainRows as $row ) {
$url = $row [ 'url' ] ? ? null ;
if ( ! is_string ( $url ) || $url === '' ) {
continue ;
}
$status = $row [ 'dns_status' ] ? ? 'pending' ;
if ( $status === 'pending' ) {
// Keep previously stored value for unchecked domains if present.
$key = $this -> domainDnsStatusKey ( $url , $row [ 'service' ] ? ? null );
$existing = data_get ( $this -> storedDomainDnsStatuses (), $key );
if ( is_array ( $existing ) && filled ( data_get ( $existing , 'status' ))) {
$statuses [ $key ] = $existing ;
}
continue ;
}
$key = $this -> domainDnsStatusKey ( $url , $row [ 'service' ] ? ? null );
$statuses [ $key ] = [
'status' => $status ,
'message' => ( string ) ( $row [ 'dns_message' ] ? ? '' ),
'expected_ip' => $row [ 'expected_ip' ] ? ? $this -> serverIp ,
'checked_at' => $row [ 'checked_at' ] ? ? now () -> toIso8601String (),
2026-08-21 19:22:04 +00:00
'check_id' => $row [ 'check_id' ] ? ? null ,
2026-08-03 21:11:54 +00:00
];
}
2026-08-21 19:22:04 +00:00
DB :: transaction ( function () use ( & $statuses ) : void {
$application = Application :: query () -> lockForUpdate () -> findOrFail ( $this -> application -> id );
$storedStatuses = $application -> domain_dns_statuses ? ? [];
foreach ( $statuses as $key => $status ) {
$localCheckId = $status [ 'check_id' ] ? ? null ;
$storedCheckId = $storedStatuses [ $key ][ 'check_id' ] ? ? null ;
if ( $storedCheckId !== null && $localCheckId !== $storedCheckId ) {
$statuses [ $key ] = $storedStatuses [ $key ];
continue ;
}
if ( $status [ 'status' ] === 'checking' && isset ( $storedStatuses [ $key ]) && $storedStatuses [ $key ][ 'status' ] !== 'checking' ) {
$statuses [ $key ] = $storedStatuses [ $key ];
}
}
$application -> domain_dns_statuses = $statuses === [] ? null : $statuses ;
$application -> save ();
});
2026-08-03 21:11:54 +00:00
$this -> application -> domain_dns_statuses = $statuses === [] ? null : $statuses ;
}
protected function pruneDomainDnsStatusesToCurrentDomains () : void
{
$this -> application -> refresh ();
// Rebuild rows from current domain list, keep only matching stored keys.
$this -> domainRows = $this -> buildDomainRows ();
$this -> persistDomainDnsStatuses ();
}
/**
* Human - readable DNS target : real IP , with configured hostname when it differs .
*/
public function dnsTargetLabel () : ? string
{
if ( blank ( $this -> serverIp )) {
return $this -> serverIpConfigured ;
}
if (
filled ( $this -> serverIpConfigured )
&& $this -> serverIpConfigured !== $this -> serverIp
&& filter_var ( $this -> serverIpConfigured , FILTER_VALIDATE_IP ) === false
) {
return " { $this -> serverIp } ( { $this -> serverIpConfigured } ) " ;
}
return $this -> serverIp ;
}
public function updatedNewDomain () : void
{
$this -> resetAddDomainDnsGate ();
}
2026-08-17 14:05:21 +00:00
public function updatedNewDomainParts () : void
{
$this -> newDomainPartsChanged = true ;
$this -> resetAddDomainDnsGate ();
}
2026-08-03 21:11:54 +00:00
public function updatedNewDomainService () : void
{
$this -> resetAddDomainDnsGate ();
}
public function resetAddDomainDnsGate () : void
{
$this -> addDomainDnsFailed = false ;
$this -> addDomainDnsMessage = '' ;
$this -> forceSaveDns = false ;
}
public function resetAddDomainForm () : void
{
$this -> newDomain = '' ;
2026-08-17 14:05:21 +00:00
$this -> newDomainParts = DomainUrlParts :: empty ();
$this -> newDomainPartsChanged = false ;
2026-08-03 21:11:54 +00:00
$this -> resetAddDomainDnsGate ();
$this -> resetErrorBag ( 'newDomain' );
}
public function confirmAddDomainDespiteDns () : void
{
$this -> forceSaveDns = true ;
$this -> addDomain ();
}
public function confirmDomainUsage () : void
{
$this -> forceSaveDomains = true ;
$this -> showDomainConflictModal = false ;
if ( $this -> pendingAction === 'update' ) {
$this -> updateDomain ();
return ;
}
if ( $this -> pendingAction === 'suggested' ) {
$this -> addSuggestedDomain (( int ) ( $this -> editingIndex ? ? - 1 ));
return ;
}
if ( $this -> pendingAction === 'redirect' ) {
if ( $this -> isCompose && filled ( $this -> pendingRedirectService )) {
$this -> setServiceRedirect ( $this -> pendingRedirectService );
return ;
}
$this -> setRedirect ();
return ;
}
$this -> addDomain ();
}
2026-09-02 17:39:19 +00:00
public function confirmUseUnknownPort () : void
{
$this -> authorize ( 'update' , $this -> application );
$this -> forceUseUnknownPort = true ;
$this -> showPortWarningModal = false ;
$action = $this -> pendingPortAction ;
$this -> pendingPortAction = null ;
if ( $action === 'update' ) {
$this -> updateDomain ();
return ;
}
$this -> addDomain ();
}
public function cancelUseUnknownPort () : void
{
$this -> showPortWarningModal = false ;
$this -> forceUseUnknownPort = false ;
$this -> unrecognizedPort = null ;
$this -> pendingPortAction = null ;
}
2026-08-03 21:11:54 +00:00
/**
* Clear pending conflict state when the modal is dismissed without confirmation .
* confirmDomainUsage sets forceSaveDomains before closing the modal .
*/
public function updatedShowDomainConflictModal ( bool $value ) : void
{
if ( $value || $this -> forceSaveDomains ) {
return ;
}
2026-09-07 16:29:58 +00:00
$this -> authorize ( 'update' , $this -> application );
$wasRedirect = $this -> pendingAction === 'redirect' ;
2026-08-03 21:11:54 +00:00
$this -> pendingAction = null ;
2026-09-07 16:29:58 +00:00
$this -> pendingRedirectService = null ;
if ( $wasRedirect ) {
$this -> refreshDomains ();
}
2026-08-03 21:11:54 +00:00
}
public function addDomain () : void
{
try {
$this -> authorize ( 'update' , $this -> application );
if ( $this -> labelsAreWritable ) {
$this -> dispatch ( 'error' , 'Domains cannot be edited while container labels are writable. Set domains in the Labels section on General.' );
return ;
}
2026-09-02 17:39:19 +00:00
if ( $this -> newDomainPartsChanged || filled ( $this -> newDomainParts [ 'host' ] ? ? null )) {
2026-08-17 14:05:21 +00:00
$this -> newDomain = DomainUrlParts :: compose ( ... $this -> newDomainParts );
}
2026-08-03 21:11:54 +00:00
$this -> validateOnly ( 'newDomain' );
$normalized = ValidationPatterns :: normalizeApplicationDomains ( $this -> newDomain );
if ( blank ( $normalized )) {
$this -> addError ( 'newDomain' , 'Please enter a valid domain URL.' );
return ;
}
$newUrls = $this -> splitDomains ( $normalized );
2026-08-05 11:50:11 +00:00
$pairedUrls = collect ( $newUrls )
-> map ( fn ( string $url ) => $this -> wwwCounterpartUrl ( $url ))
-> filter ()
-> values ()
-> all ();
2026-08-03 21:11:54 +00:00
$current = $this -> currentDomainList ( $this -> newDomainService );
2026-09-02 17:39:19 +00:00
$currentCanonicalDomains = $current -> map (
fn ( string $url ) : string => DomainPortOverrides :: withoutPort ( $url )
);
2026-08-03 21:11:54 +00:00
foreach ( $newUrls as $url ) {
2026-09-02 17:39:19 +00:00
if ( $currentCanonicalDomains -> contains ( DomainPortOverrides :: withoutPort ( $url ))) {
2026-08-03 21:11:54 +00:00
$this -> addError ( 'newDomain' , " Domain { $url } is already configured. " );
return ;
}
}
2026-09-07 13:17:23 +00:00
if ( $this -> shouldConfirmPort ( $this -> portFromParts ( $this -> newDomainParts ), serviceName : $this -> newDomainService )) {
2026-09-02 17:39:19 +00:00
$this -> openPortWarning ( $this -> portFromParts ( $this -> newDomainParts ), 'add' );
return ;
}
2026-08-05 11:50:11 +00:00
$merged = $current -> merge ( $newUrls ) -> merge ( $pairedUrls ) -> unique () -> values ();
2026-08-03 21:11:54 +00:00
$this -> pendingAction = 'add' ;
2026-08-05 11:50:11 +00:00
if ( ! $this -> saveDomainList ( $merged , $this -> newDomainService )) {
2026-08-03 21:11:54 +00:00
return ;
}
$this -> forceSaveDomains = false ;
$this -> pendingAction = null ;
2026-09-02 17:39:19 +00:00
$this -> forceUseUnknownPort = false ;
2026-08-03 21:11:54 +00:00
$serviceForCheck = $this -> newDomainService ;
$this -> resetAddDomainForm ();
$this -> dispatch ( 'close-modal' );
$this -> refreshDomains ();
2026-08-21 19:22:04 +00:00
$urlsToCheck = array_values ( array_unique ( array_merge ( $newUrls , $pairedUrls )));
$dnsChecks = collect ( $this -> dnsEntriesForUrls ( $urlsToCheck , $serviceForCheck ))
-> map ( fn ( string $url , string $statusKey ) => [
'status_key' => $statusKey ,
'url' => $url ,
'check_id' => new_public_id (),
]);
foreach ( $dnsChecks as $dnsCheck ) {
$this -> markUrlsAsChecking ([ $dnsCheck [ 'url' ]], $serviceForCheck , $dnsCheck [ 'check_id' ]);
}
$this -> persistDomainDnsStatuses ();
$failedDnsChecks = 0 ;
foreach ( $dnsChecks as $dnsCheck ) {
try {
CheckDomainDnsJob :: dispatch (
$this -> application ,
$dnsCheck [ 'status_key' ],
$dnsCheck [ 'url' ],
$this -> application -> destination ? -> server ,
$this -> serverIp ,
$dnsCheck [ 'check_id' ],
$this -> application -> additional_servers -> count () > 0 ,
);
} catch ( \Throwable ) {
$failedDnsChecks ++ ;
$this -> markUrlsDnsCheckUnavailable ([ $dnsCheck [ 'url' ]], $serviceForCheck , $dnsCheck [ 'check_id' ]);
}
}
if ( $failedDnsChecks > 0 ) {
$this -> persistDomainDnsStatuses ();
$this -> dispatch ( 'error' , 'Some DNS checks could not be started. Try again from the Domains page.' );
}
$this -> dispatch ( 'success' , $failedDnsChecks === $dnsChecks -> count ()
? 'Domain added.'
: 'Domain added. DNS check started.' );
2026-08-03 21:11:54 +00:00
} catch ( \Throwable $e ) {
handleError ( $e , $this );
}
}
2026-08-21 19:22:04 +00:00
/**
* @ param array < int , string > $urls
*/
protected function markUrlsAsChecking ( array $urls , ? string $service = null , ? string $checkId = null ) : void
{
$indexesToCheck = [];
foreach ( $this -> domainRows as $index => $row ) {
if ( ! in_array ( $row [ 'url' ], $urls , true )) {
continue ;
}
if ( $service !== null && ( $row [ 'service' ] ? ? null ) !== $service ) {
continue ;
}
$this -> domainRows [ $index ][ 'dns_status' ] = 'checking' ;
$this -> domainRows [ $index ][ 'dns_message' ] = 'Checking DNS...' ;
$this -> domainRows [ $index ][ 'check_id' ] = $checkId ;
}
}
/**
* @ param array < int , string > $urls
*/
protected function markUrlsDnsCheckUnavailable ( array $urls , ? string $service = null , ? string $checkId = null ) : void
{
$this -> markUrlsAsChecking ( $urls , $service , $checkId );
foreach ( $this -> domainRows as $index => $row ) {
if ( ! in_array ( $row [ 'url' ], $urls , true )) {
continue ;
}
if ( $service !== null && ( $row [ 'service' ] ? ? null ) !== $service ) {
continue ;
}
$this -> domainRows [ $index ][ 'dns_status' ] = 'skipped' ;
$this -> domainRows [ $index ][ 'dns_message' ] = 'DNS check could not be started.' ;
$this -> domainRows [ $index ][ 'checked_at' ] = now () -> toIso8601String ();
}
}
/**
* @ param array < int , string > $urls
* @ return array < string , string >
*/
protected function dnsEntriesForUrls ( array $urls , ? string $service = null ) : array
{
$entries = [];
foreach ( $urls as $url ) {
$entries [ $this -> domainDnsStatusKey ( $url , $service )] = $url ;
}
return $entries ;
}
2026-08-03 21:11:54 +00:00
/**
* Run a first - time DNS check for newly added / updated domain URLs and persist results .
*
* @ param array < int , string > $urls
*/
protected function checkUrlsDns ( array $urls , ? string $service = null ) : void
{
if ( $urls === []) {
return ;
}
$urlSet = array_fill_keys ( $urls , true );
$server = $this -> application -> destination ? -> server ;
$skipDns = ! $this -> dnsValidationEnabled
|| ! $server
|| $this -> application -> additional_servers -> count () > 0 ;
2026-08-30 14:20:16 +00:00
$indexesToCheck = [];
2026-08-03 21:11:54 +00:00
foreach ( $this -> domainRows as $index => $row ) {
$url = $row [ 'url' ] ? ? null ;
if ( ! is_string ( $url ) || ! isset ( $urlSet [ $url ])) {
continue ;
}
if ( $service !== null && ( $row [ 'service' ] ? ? null ) !== $service ) {
continue ;
}
if ( $skipDns ) {
$reason = ! $this -> dnsValidationEnabled
? 'DNS validation is disabled in instance settings.'
: ( $this -> application -> additional_servers -> count () > 0
? 'DNS check skipped for multi-server applications.'
: 'No server available for DNS validation.' );
$this -> domainRows [ $index ][ 'dns_status' ] = 'skipped' ;
$this -> domainRows [ $index ][ 'dns_message' ] = $reason ;
$this -> domainRows [ $index ][ 'checked_at' ] = now () -> toIso8601String ();
$this -> domainRows [ $index ][ 'expected_ip' ] = $this -> serverIp ;
continue ;
}
2026-08-21 19:22:04 +00:00
$indexesToCheck [] = $index ;
}
if ( $server && $indexesToCheck !== []) {
$this -> applyDnsStatuses ( $indexesToCheck , $server );
2026-08-03 21:11:54 +00:00
}
$this -> persistDomainDnsStatuses ();
}
2026-09-10 12:00:50 +00:00
/**
* @ param array < int , string > $urls
*/
protected function queueUrlsDns ( array $urls , ? string $service = null ) : void
{
foreach ( $this -> dnsEntriesForUrls ( $urls , $service ) as $statusKey => $url ) {
$checkId = new_public_id ();
$this -> markUrlsAsChecking ([ $url ], $service , $checkId );
$this -> persistDomainDnsStatuses ();
try {
CheckDomainDnsJob :: dispatch (
$this -> application ,
$statusKey ,
$url ,
$this -> application -> destination ? -> server ,
$this -> serverIp ,
$checkId ,
$this -> application -> additional_servers -> count () > 0 ,
);
} catch ( \Throwable ) {
$this -> markUrlsDnsCheckUnavailable ([ $url ], $service , $checkId );
$this -> persistDomainDnsStatuses ();
$this -> dispatch ( 'error' , 'The DNS check could not be started. Try again from the Domains page.' );
}
}
}
2026-08-03 21:11:54 +00:00
protected function shouldValidateDnsForAdd () : bool
{
if ( ! $this -> dnsValidationEnabled ) {
return false ;
}
$server = $this -> application -> destination ? -> server ;
if ( ! $server ) {
return false ;
}
if ( $this -> application -> additional_servers -> count () > 0 ) {
return false ;
}
return true ;
}
/**
* @ param array < int , string > $urls
*/
protected function findDnsFailureMessage ( array $urls ) : ? string
{
$server = $this -> application -> destination ? -> server ;
if ( ! $server ) {
return null ;
}
2026-08-21 19:22:04 +00:00
$results = CheckDomainDns :: run ( array_combine ( $urls , $urls ), $server , $this -> serverIp );
2026-08-03 21:11:54 +00:00
2026-08-21 19:22:04 +00:00
foreach ( $results as $result ) {
if ( $result [ 'status' ] === 'failed' ) {
return $result [ 'message' ];
2026-08-03 21:11:54 +00:00
}
}
return null ;
}
public function updatedEditingDomain () : void
{
$this -> resetEditDomainDnsGate ();
}
2026-08-17 14:05:21 +00:00
public function updatedEditingDomainParts () : void
{
$this -> editingDomainPartsChanged = true ;
$this -> resetEditDomainDnsGate ();
}
2026-08-03 21:11:54 +00:00
public function resetEditDomainDnsGate () : void
{
$this -> editDomainDnsFailed = false ;
$this -> editDomainDnsMessage = '' ;
$this -> forceSaveEditDns = false ;
}
public function startEdit ( int $index ) : void
{
if ( ! isset ( $this -> domainRows [ $index ]) || ( $this -> domainRows [ $index ][ 'is_suggested' ] ? ? false )) {
return ;
}
$this -> editingIndex = $index ;
$this -> editingDomain = $this -> domainRows [ $index ][ 'url' ];
2026-08-17 14:05:21 +00:00
$this -> editingDomainParts = DomainUrlParts :: split ( $this -> editingDomain );
2026-09-02 17:39:19 +00:00
$canonical = DomainPortOverrides :: withoutPort ( $this -> editingDomain );
$savedPort = ( $this -> application -> domain_port_overrides ? ? [])[ $canonical ] ? ? null ;
if ( filled ( $savedPort )) {
$this -> editingDomainParts [ 'port' ] = ( string ) $savedPort ;
}
2026-08-17 14:05:21 +00:00
$this -> editingDomainPartsChanged = false ;
2026-08-03 21:11:54 +00:00
$this -> editingService = $this -> domainRows [ $index ][ 'service' ];
2026-09-10 12:00:50 +00:00
$this -> editingIndexing = $this -> application -> isDomainNoindexed ( $this -> editingDomain ) ? 'noindex' : 'index' ;
$this -> editingRedirect = $this -> serviceRedirectFor ( $this -> editingService );
$this -> editingOriginalRedirect = $this -> editingRedirect ;
$this -> editingDomainWasRegenerated = false ;
$this -> editingGeneratedHost = null ;
2026-08-03 21:11:54 +00:00
$this -> resetEditDomainDnsGate ();
$this -> resetErrorBag ( 'editingDomain' );
$this -> showEditDomainModal = true ;
2026-08-17 14:05:21 +00:00
$this -> dispatch ( 'open-edit-domain' );
2026-08-03 21:11:54 +00:00
}
public function addSuggestedDomain ( int $index ) : void
{
try {
$this -> authorize ( 'update' , $this -> application );
if ( $this -> labelsAreWritable ) {
$this -> dispatch ( 'error' , 'Domains cannot be edited while container labels are writable.' );
return ;
}
if ( ! isset ( $this -> domainRows [ $index ]) || ! ( $this -> domainRows [ $index ][ 'is_suggested' ] ? ? false )) {
return ;
}
$url = ( string ) $this -> domainRows [ $index ][ 'url' ];
$normalized = ValidationPatterns :: normalizeApplicationDomains ( $url );
if ( blank ( $normalized )) {
$this -> dispatch ( 'error' , 'Invalid suggested domain.' );
return ;
}
$serviceName = $this -> domainRows [ $index ][ 'service' ] ? ? null ;
$newUrls = $this -> splitDomains ( $normalized );
$current = $this -> currentDomainList ( $serviceName );
foreach ( $newUrls as $newUrl ) {
if ( $current -> contains ( $newUrl )) {
$this -> refreshDomains ();
return ;
}
}
$force = $this -> forceAddSuggestedIndex === $index
|| ( bool ) ( $this -> domainRows [ $index ][ 'needs_force_add' ] ? ? false );
if ( ! $force && $this -> shouldValidateDnsForAdd ()) {
$dnsFailure = $this -> findDnsFailureMessage ( $newUrls );
if ( $dnsFailure !== null ) {
$this -> domainRows [ $index ][ 'dns_status' ] = 'failed' ;
$this -> domainRows [ $index ][ 'dns_message' ] = $dnsFailure ;
$this -> domainRows [ $index ][ 'checked_at' ] = now () -> toIso8601String ();
$this -> domainRows [ $index ][ 'needs_force_add' ] = true ;
$this -> forceAddSuggestedIndex = $index ;
$this -> editingIndex = $index ;
$this -> persistDomainDnsStatuses ();
return ;
}
}
$merged = $current -> merge ( $newUrls ) -> unique () -> values ();
$this -> pendingAction = 'suggested' ;
$this -> editingIndex = $index ;
2026-08-05 11:50:11 +00:00
if ( ! $this -> saveDomainList ( $merged , $serviceName )) {
2026-08-03 21:11:54 +00:00
return ;
}
$this -> forceAddSuggestedIndex = null ;
$this -> editingIndex = null ;
$this -> pendingAction = null ;
$this -> forceSaveDomains = false ;
$this -> dispatch ( 'success' , 'Domain added.' );
$this -> refreshDomains ();
$this -> checkUrlsDns ( $newUrls , $serviceName );
} catch ( \Throwable $e ) {
handleError ( $e , $this );
}
}
public function cancelEdit () : void
{
$this -> showEditDomainModal = false ;
$this -> editingIndex = null ;
$this -> editingDomain = '' ;
2026-08-17 14:05:21 +00:00
$this -> editingDomainParts = DomainUrlParts :: empty ();
$this -> editingDomainPartsChanged = false ;
2026-08-03 21:11:54 +00:00
$this -> editingService = null ;
2026-09-10 12:00:50 +00:00
$this -> editingIndexing = 'index' ;
$this -> editingRedirect = 'both' ;
$this -> editingOriginalRedirect = 'both' ;
$this -> editingDomainWasRegenerated = false ;
$this -> editingGeneratedHost = null ;
2026-08-03 21:11:54 +00:00
$this -> resetEditDomainDnsGate ();
$this -> resetErrorBag ( 'editingDomain' );
if ( $this -> pendingAction === 'update' ) {
$this -> pendingAction = null ;
$this -> forceSaveDomains = false ;
$this -> showDomainConflictModal = false ;
}
}
public function confirmUpdateDomainDespiteDns () : void
{
$this -> forceSaveEditDns = true ;
$this -> updateDomain ();
}
2026-09-10 12:00:50 +00:00
public function regenerateEditingDomain () : void
{
$this -> authorize ( 'update' , $this -> application );
if ( $this -> labelsAreWritable || $this -> editingIndex === null || ! isset ( $this -> domainRows [ $this -> editingIndex ])) {
return ;
}
$server = data_get ( $this -> application , 'destination.server' );
if ( ! $server ) {
$this -> dispatch ( 'error' , 'No server found for this application.' );
return ;
}
$generatedHost = parse_url ( generateUrl ( server : $server , random : new_public_id ()), PHP_URL_HOST );
if ( ! is_string ( $generatedHost ) || $generatedHost === '' ) {
$this -> dispatch ( 'error' , 'Could not generate a domain.' );
return ;
}
$currentHost = ( string ) ( $this -> editingDomainParts [ 'host' ] ? ? '' );
$this -> editingGeneratedHost = $generatedHost ;
$this -> editingDomainParts [ 'host' ] = str_starts_with ( strtolower ( $currentHost ), 'www.' )
? 'www.' . $generatedHost
: $generatedHost ;
$this -> editingDomainPartsChanged = true ;
$this -> editingDomainWasRegenerated = true ;
$this -> resetEditDomainDnsGate ();
$this -> resetErrorBag ( 'editingDomain' );
}
2026-08-03 21:11:54 +00:00
public function updateDomain () : void
{
try {
$this -> authorize ( 'update' , $this -> application );
if ( $this -> labelsAreWritable ) {
$this -> dispatch ( 'error' , 'Domains cannot be edited while container labels are writable.' );
return ;
}
if ( $this -> editingIndex === null || ! isset ( $this -> domainRows [ $this -> editingIndex ])) {
return ;
}
2026-09-02 17:39:19 +00:00
if ( $this -> editingDomainPartsChanged || filled ( $this -> editingDomainParts [ 'host' ] ? ? null )) {
2026-08-17 14:05:21 +00:00
$this -> editingDomain = DomainUrlParts :: compose ( ... $this -> editingDomainParts );
}
2026-08-03 21:11:54 +00:00
$this -> validateOnly ( 'editingDomain' );
2026-09-10 12:00:50 +00:00
$this -> validateOnly ( 'editingIndexing' );
$this -> validateOnly ( 'editingRedirect' );
2026-08-03 21:11:54 +00:00
$normalized = ValidationPatterns :: normalizeApplicationDomains ( $this -> editingDomain );
if ( blank ( $normalized ) || count ( $this -> splitDomains ( $normalized )) !== 1 ) {
$this -> addError ( 'editingDomain' , 'Please enter a single valid domain URL.' );
return ;
}
$newUrl = $this -> splitDomains ( $normalized )[ 0 ];
$oldUrl = $this -> domainRows [ $this -> editingIndex ][ 'url' ];
$service = $this -> editingService ;
2026-09-02 17:39:19 +00:00
if ( blank ( DomainUrlParts :: split ( $newUrl )[ 'port' ] ? ? null )) {
$portOverrides = $this -> application -> domain_port_overrides ? ? [];
unset ( $portOverrides [ DomainPortOverrides :: withoutPort ( $oldUrl )]);
unset ( $portOverrides [ DomainPortOverrides :: withoutPort ( $newUrl )]);
$this -> application -> domain_port_overrides = $portOverrides ? : null ;
}
2026-08-03 21:11:54 +00:00
$current = $this -> currentDomainList ( $service );
2026-09-02 17:39:19 +00:00
$otherCanonicalDomains = $current
-> reject ( fn ( string $url ) : bool => $url === $oldUrl )
-> map ( fn ( string $url ) : string => DomainPortOverrides :: withoutPort ( $url ));
if ( $otherCanonicalDomains -> contains ( DomainPortOverrides :: withoutPort ( $newUrl ))) {
2026-08-03 21:11:54 +00:00
$this -> addError ( 'editingDomain' , " Domain { $newUrl } is already configured. " );
return ;
}
2026-09-07 13:17:23 +00:00
if ( $this -> shouldConfirmPort ( $this -> portFromParts ( $this -> editingDomainParts ), $this -> currentRowPort ( $oldUrl ), $service )) {
2026-09-02 17:39:19 +00:00
$this -> openPortWarning ( $this -> portFromParts ( $this -> editingDomainParts ), 'update' );
return ;
}
2026-09-10 12:00:50 +00:00
$replacements = [ $oldUrl => $newUrl ];
if ( $this -> editingDomainWasRegenerated && filled ( $this -> editingGeneratedHost ) && in_array ( $this -> editingRedirect , [ 'www' , 'non-www' ], true )) {
$oldCounterpartHost = parse_url (( string ) $this -> wwwCounterpartUrl ( $oldUrl , true ), PHP_URL_HOST );
$oldCounterpart = $current -> first ( fn ( string $url ) : bool => parse_url ( $url , PHP_URL_HOST ) === $oldCounterpartHost );
if ( is_string ( $oldCounterpart )) {
$counterpartParts = DomainUrlParts :: split ( $oldCounterpart );
$counterpartPort = $this -> currentRowPort ( $oldCounterpart );
if ( $counterpartPort !== null ) {
$counterpartParts [ 'port' ] = ( string ) $counterpartPort ;
}
$counterpartParts [ 'host' ] = str_starts_with ( strtolower ( $counterpartParts [ 'host' ]), 'www.' )
? 'www.' . $this -> editingGeneratedHost
: $this -> editingGeneratedHost ;
$replacements [ $oldCounterpart ] = DomainUrlParts :: compose ( ... $counterpartParts );
}
}
2026-08-03 21:11:54 +00:00
2026-09-10 12:00:50 +00:00
$updated = $current -> map ( fn ( string $url ) => $replacements [ $url ] ? ? $url ) -> unique () -> values ();
if ( $this -> editingRedirect !== $this -> editingOriginalRedirect && in_array ( $this -> editingRedirect , [ 'www' , 'non-www' ], true )) {
foreach ( $updated -> all () as $url ) {
$counterpart = $this -> wwwCounterpartUrl ( $url , true );
$counterpartHost = is_string ( $counterpart ) ? parse_url ( $counterpart , PHP_URL_HOST ) : null ;
$hasCounterpart = filled ( $counterpartHost ) && $updated -> contains (
fn ( string $candidate ) : bool => parse_url ( $candidate , PHP_URL_HOST ) === $counterpartHost
);
if ( filled ( $counterpart ) && ! $hasCounterpart ) {
$updated -> push ( $counterpart );
}
2026-08-03 21:11:54 +00:00
}
}
2026-09-10 12:00:50 +00:00
$urlsToCheck = $updated
-> reject ( fn ( string $url ) : bool => $current -> contains (
fn ( string $existingUrl ) : bool => ! DomainUrlParts :: hasDnsRelevantChange ( $existingUrl , $url )
))
-> map ( fn ( string $url ) : string => DomainPortOverrides :: withoutPort ( $url ))
-> unique ()
-> values ()
-> all ();
$noindexDomains = $this -> application -> noindexDomains ();
foreach ( $replacements as $previousUrl => $replacementUrl ) {
$wasNoindexed = $previousUrl === $oldUrl
? $this -> editingIndexing === 'noindex'
: $this -> application -> isDomainNoindexed ( $previousUrl );
$noindexDomains = $noindexDomains -> reject ( fn ( string $domain ) : bool => $domain === $previousUrl );
if ( $wasNoindexed ) {
$noindexDomains -> push ( $replacementUrl );
}
}
if ( $this -> isCompose ) {
$allDomains = json_decode ( $this -> application -> docker_compose_domains ? : '[]' , true );
$existing = is_array ( $allDomains [ $service ] ? ? null ) ? $allDomains [ $service ] : [];
$allDomains [ $service ] = array_merge ( $existing , [ 'redirect' => $this -> editingRedirect ]);
$this -> application -> docker_compose_domains = json_encode ( $allDomains );
} else {
$this -> application -> redirect = $this -> editingRedirect ;
}
2026-08-03 21:11:54 +00:00
$this -> pendingAction = 'update' ;
2026-09-10 12:00:50 +00:00
if ( ! $this -> saveDomainList ( $updated , $service , noindexDomains : $noindexDomains )) {
2026-08-03 21:11:54 +00:00
return ;
}
2026-08-10 13:10:30 +00:00
$this -> resetDefaultLabels ();
2026-08-03 21:11:54 +00:00
$this -> forceSaveDomains = false ;
$this -> pendingAction = null ;
2026-09-02 17:39:19 +00:00
$this -> forceUseUnknownPort = false ;
2026-08-03 21:11:54 +00:00
$this -> cancelEdit ();
2026-08-06 11:45:19 +00:00
$this -> dispatch ( 'edit-domain-saved' );
2026-08-03 21:11:54 +00:00
$this -> dispatch ( 'success' , 'Domain updated.' );
$this -> refreshDomains ();
2026-09-10 12:00:50 +00:00
if ( $urlsToCheck !== []) {
$this -> queueUrlsDns ( $urlsToCheck , $service );
}
2026-08-03 21:11:54 +00:00
} catch ( \Throwable $e ) {
handleError ( $e , $this );
}
}
public function removeDomain ( int $index ) : void
{
try {
$this -> authorize ( 'update' , $this -> application );
if ( $this -> labelsAreWritable ) {
$this -> dispatch ( 'error' , 'Domains cannot be edited while container labels are writable.' );
return ;
}
if ( ! isset ( $this -> domainRows [ $index ]) || ( $this -> domainRows [ $index ][ 'is_suggested' ] ? ? false )) {
return ;
}
$url = $this -> domainRows [ $index ][ 'url' ];
$service = $this -> domainRows [ $index ][ 'service' ];
$updated = $this -> currentDomainList ( $service ) -> reject ( fn ( string $item ) => $item === $url ) -> values ();
2026-08-05 11:50:11 +00:00
if ( ! $this -> saveDomainList ( $updated , $service , checkConflicts : false )) {
2026-08-03 21:11:54 +00:00
return ;
}
if ( $this -> editingIndex === $index ) {
$this -> cancelEdit ();
}
$this -> dispatch ( 'success' , 'Domain removed.' );
$this -> refreshDomains ();
$this -> pruneDomainDnsStatusesToCurrentDomains ();
} catch ( \Throwable $e ) {
handleError ( $e , $this );
}
}
2026-08-28 07:26:57 +00:00
public function removeDomainByKey ( string $domainKey ) : void
{
$index = collect ( $this -> domainRows ) -> search (
fn ( array $row ) : bool => ! ( $row [ 'is_suggested' ] ? ? false )
&& hash_equals ( $domainKey , $this -> domainRowKey ( $row ))
);
if ( $index === false ) {
return ;
}
$this -> removeDomain (( int ) $index );
}
/**
* @ param array { url : string , service ? : ? string } $row
*/
private function domainRowKey ( array $row ) : string
{
return hash ( 'sha256' , $row [ 'url' ] . '|' . ( $row [ 'service' ] ? ? '' ));
}
2026-08-03 21:11:54 +00:00
public function generateDomain ( ? string $serviceName = null ) : void
{
try {
$this -> authorize ( 'update' , $this -> application );
if ( $this -> labelsAreWritable ) {
$this -> dispatch ( 'error' , 'Domains cannot be edited while container labels are writable.' );
return ;
}
$server = data_get ( $this -> application , 'destination.server' );
if ( ! $server ) {
$this -> dispatch ( 'error' , 'No server found for this application.' );
return ;
}
if ( $this -> isCompose ) {
$serviceName = $serviceName ? : $this -> newDomainService ? : ( $this -> composeServices [ 0 ] ? ? null );
if ( ! $serviceName ) {
$this -> dispatch ( 'error' , 'No compose service available for domain generation.' );
return ;
}
$domain = generateUrl ( server : $server , random : new_public_id ());
$current = $this -> currentDomainList ( $serviceName );
$merged = $current -> push ( $domain ) -> unique () -> values ();
2026-08-05 11:50:11 +00:00
if ( ! $this -> saveDomainList ( $merged , $serviceName , checkConflicts : false )) {
2026-08-03 21:11:54 +00:00
return ;
}
2026-08-05 11:50:11 +00:00
$pairedUrls = $this -> syncRedirectDomainPairs ( $serviceName );
2026-08-03 21:11:54 +00:00
$this -> resetAddDomainForm ();
$this -> dispatch ( 'close-modal' );
$this -> dispatch ( 'success' , 'Domain generated.' );
$this -> refreshDomains ();
2026-08-05 11:50:11 +00:00
$this -> checkUrlsDns ( array_values ( array_unique ( array_merge ([ $domain ], $pairedUrls ))), $serviceName );
2026-08-03 21:11:54 +00:00
return ;
}
$fqdn = generateUrl ( server : $server , random : $this -> application -> uuid );
2026-08-05 11:50:11 +00:00
$merged = $this -> currentDomainList () -> push ( $fqdn ) -> unique () -> values ();
if ( ! $this -> saveDomainList ( $merged , null , checkConflicts : false )) {
return ;
}
$pairedUrls = $this -> syncRedirectDomainPairs ( null );
2026-08-03 21:11:54 +00:00
$this -> resetAddDomainForm ();
$this -> dispatch ( 'close-modal' );
$this -> dispatch ( 'success' , 'Domain generated.' );
$this -> refreshDomains ();
2026-08-05 11:50:11 +00:00
$this -> checkUrlsDns ( array_values ( array_unique ( array_merge ([ $fqdn ], $pairedUrls ))));
2026-08-03 21:11:54 +00:00
} catch ( \Throwable $e ) {
handleError ( $e , $this );
}
}
public function setRedirect () : void
{
try {
$this -> authorize ( 'update' , $this -> application );
if ( $this -> isCompose ) {
$this -> dispatch ( 'error' , 'Set the redirect direction per compose service.' );
return ;
}
$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.
2026-08-10 13:10:30 +00:00
$addedDomains = [];
2026-08-03 21:11:54 +00:00
if ( in_array ( $this -> redirect , [ 'www' , 'non-www' ], true )) {
2026-08-10 13:10:30 +00:00
$domainsBeforePairing = $this -> currentDomainList ();
2026-08-03 21:11:54 +00:00
if ( ! $this -> ensureWwwNonWwwPairsConfigured ( null )) {
return ;
}
$this -> application -> refresh ();
$this -> application -> redirect = $this -> redirect ;
2026-08-10 13:10:30 +00:00
$addedDomains = $this -> currentDomainList () -> diff ( $domainsBeforePairing ) -> values () -> all ();
2026-08-03 21:11:54 +00:00
}
$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 ();
2026-09-10 12:00:50 +00:00
$this -> queueUrlsDns ( $addedDomains );
2026-08-03 21:11:54 +00:00
$this -> pruneDomainDnsStatusesToCurrentDomains ();
} catch ( \Throwable $e ) {
handleError ( $e , $this );
}
}
2026-08-10 22:06:55 +00:00
public function updateServiceRedirect ( string $serviceName , string $redirect ) : void
{
$this -> serviceRedirects [ $this -> serviceRedirectWireKey ( $serviceName )] = $redirect ;
$this -> setServiceRedirect ( $serviceName );
}
2026-08-03 21:11:54 +00:00
/**
* @ 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.
2026-08-10 13:10:30 +00:00
$addedDomains = [];
2026-08-03 21:11:54 +00:00
if ( in_array ( $redirect , [ 'www' , 'non-www' ], true )) {
2026-08-10 13:10:30 +00:00
$domainsBeforePairing = $this -> currentDomainList ( $serviceName );
2026-08-03 21:11:54 +00:00
if ( ! $this -> ensureWwwNonWwwPairsConfigured ( $serviceName )) {
return ;
}
// Ensure we re-read domains after pair save before writing redirect.
$this -> application -> refresh ();
2026-08-10 13:10:30 +00:00
$addedDomains = $this -> currentDomainList ( $serviceName ) -> diff ( $domainsBeforePairing ) -> values () -> all ();
2026-08-03 21:11:54 +00:00
}
$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 ();
2026-08-10 13:15:48 +00:00
if ( $this -> notifyRedirectUpdate ) {
$this -> dispatch ( 'success' , " Redirect updated for { $serviceName } . " );
}
2026-08-03 21:11:54 +00:00
$this -> refreshDomains ();
2026-09-10 12:00:50 +00:00
$this -> queueUrlsDns ( $addedDomains , $serviceName );
2026-08-03 21:11:54 +00:00
$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 ;
}
2026-08-05 11:50:11 +00:00
/**
* When saved redirect is www / non - www , ensure missing counterparts exist as real domains
* ( not suggestion rows the user must click Add domain for ) .
*
* @ return array < int , string > newly added domain URLs
*/
protected function syncRedirectDomainPairs ( ? string $serviceName = null ) : array
{
if ( $this -> labelsAreWritable ) {
return [];
}
$user = auth () -> user ();
if ( $user === null || ! $user -> can ( 'update' , $this -> application )) {
return [];
}
if ( $this -> isCompose && $serviceName === null ) {
$added = [];
$serviceNames = $this -> composeServices ;
$domains = $this -> application -> docker_compose_domains
? json_decode ( $this -> application -> docker_compose_domains , true )
: [];
if ( is_array ( $domains )) {
foreach ( array_keys ( $domains ) as $name ) {
if ( ! in_array ( $name , $serviceNames , true )) {
$serviceNames [] = $name ;
}
}
}
foreach ( $serviceNames as $name ) {
$added = array_merge ( $added , $this -> syncRedirectDomainPairs ( $name ));
}
return array_values ( array_unique ( $added ));
}
$redirect = $this -> savedRedirectForService ( $serviceName );
if ( ! in_array ( $redirect , [ 'www' , 'non-www' ], true )) {
return [];
}
$before = $this -> currentDomainList ( $serviceName ) -> all ();
if ( ! $this -> ensureWwwNonWwwPairsConfigured ( $serviceName )) {
return [];
}
$this -> application -> refresh ();
$after = $this -> currentDomainList ( $serviceName );
return $after -> reject ( fn ( string $url ) => in_array ( $url , $before , true )) -> values () -> all ();
}
protected function savedRedirectForService ( ? string $serviceName ) : string
{
if ( $this -> isCompose && filled ( $serviceName )) {
$domains = $this -> application -> docker_compose_domains
? json_decode ( $this -> application -> docker_compose_domains , true )
: [];
$entry = is_array ( $domains ) ? ( $domains [ $serviceName ] ? ? null ) : null ;
$stored = is_array ( $entry ) ? ( $entry [ 'redirect' ] ? ? null ) : null ;
return $this -> normalizeRedirect ( is_string ( $stored ) ? $stored : null );
}
return $this -> normalizeRedirect ( $this -> application -> redirect ? ? null );
}
2026-08-03 21:11:54 +00:00
/**
* 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.
2026-08-05 11:50:11 +00:00
if ( ! $this -> saveDomainList ( $merged , $serviceName )) {
2026-08-03 21:11:54 +00:00
return false ;
}
2026-08-05 11:50:11 +00:00
$this -> pendingAction = null ;
$this -> pendingRedirectService = null ;
2026-08-03 21:11:54 +00:00
return true ;
}
protected function domainHost ( string $url ) : ? string
{
try {
$host = parse_url ( $url , PHP_URL_HOST );
return is_string ( $host ) && $host !== '' ? $host : null ;
} catch ( \Throwable ) {
return null ;
}
}
/**
* 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 ) {
return null ;
}
$lowerHost = strtolower ( $host );
// 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 (
! $forRedirectPairing
&& ( str_contains ( $lowerHost , 'sslip.io' ) || str_contains ( $lowerHost , 'nip.io' ))
) {
return null ;
}
$counterpartHost = str_starts_with ( $lowerHost , 'www.' )
? substr ( $host , 4 )
: 'www.' . $host ;
if ( $counterpartHost === '' || $counterpartHost === $host ) {
return null ;
}
$parts = parse_url ( $url );
if ( $parts === false ) {
return null ;
}
$scheme = $parts [ 'scheme' ] ? ? 'https' ;
$port = isset ( $parts [ 'port' ]) ? ':' . $parts [ 'port' ] : '' ;
$path = $parts [ 'path' ] ? ? '' ;
$query = isset ( $parts [ 'query' ]) ? '?' . $parts [ 'query' ] : '' ;
$fragment = isset ( $parts [ 'fragment' ]) ? '#' . $parts [ 'fragment' ] : '' ;
return " { $scheme } :// { $counterpartHost } { $port } { $path } { $query } { $fragment } " ;
}
/**
* @ return Collection < int , string >
*/
protected function currentDomainList ( ? string $serviceName = null ) : Collection
{
if ( $this -> isCompose ) {
$domains = $this -> application -> docker_compose_domains
? json_decode ( $this -> application -> docker_compose_domains , true )
: [];
if ( ! is_array ( $domains )) {
$domains = [];
}
$domainString = is_array ( $domains [ $serviceName ] ? ? null )
? ( $domains [ $serviceName ][ 'domain' ] ? ? null )
: null ;
return collect ( $this -> splitDomains ( is_string ( $domainString ) ? $domainString : null ));
}
return collect ( $this -> splitDomains ( $this -> application -> fqdn ));
}
/**
* @ param Collection < int , string > $domains
*/
protected function saveDomainList (
Collection $domains ,
? string $serviceName = null ,
bool $checkConflicts = true ,
2026-09-10 12:00:50 +00:00
? Collection $noindexDomains = null ,
2026-08-03 21:11:54 +00:00
) : bool {
$domainString = $domains -> filter () -> unique () -> implode ( ',' );
$domainString = $domainString === '' ? null : ValidationPatterns :: normalizeApplicationDomains ( $domainString );
if ( $domainString ) {
$errors = ValidationPatterns :: validateApplicationDomains ( $domainString );
if ( $errors !== []) {
$this -> dispatch ( 'error' , $errors [ 0 ]);
return false ;
}
}
2026-09-02 17:39:19 +00:00
$intendedComposeOverrides = null ;
2026-08-03 21:11:54 +00:00
if ( $this -> isCompose ) {
if ( blank ( $serviceName )) {
$this -> dispatch ( 'error' , 'A service is required for compose domains.' );
return false ;
}
$allDomains = $this -> application -> docker_compose_domains
? json_decode ( $this -> application -> docker_compose_domains , true )
: [];
if ( ! is_array ( $allDomains )) {
$allDomains = [];
}
2026-09-02 17:39:19 +00:00
$previousServiceUrls = $this -> currentDomainList ( $serviceName );
$normalizedPorts = DomainPortOverrides :: normalize ( $domainString , $this -> application -> domain_port_overrides );
$domainString = $normalizedPorts [ 'fqdn' ];
$intendedComposeOverrides = $this -> mergeComposeDomainPortOverrides (
$previousServiceUrls ,
$domainString ,
$normalizedPorts [ 'overrides' ] ? ? null ,
);
2026-08-03 21:11:54 +00:00
$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 );
2026-09-02 17:39:19 +00:00
$this -> application -> domain_port_overrides = $intendedComposeOverrides ;
2026-08-03 21:11:54 +00:00
$this -> application -> fqdn = null ;
} else {
$this -> application -> fqdn = $domainString ;
}
2026-09-10 12:00:50 +00:00
if ( $noindexDomains !== null ) {
$this -> application -> setNoindexDomains ( $noindexDomains );
}
2026-08-03 21:11:54 +00:00
if ( $checkConflicts && ! $this -> forceSaveDomains ) {
$result = checkDomainUsage ( resource : $this -> application );
if ( $result [ 'hasConflicts' ]) {
$this -> domainConflicts = $result [ 'conflicts' ];
$this -> showDomainConflictModal = true ;
// Revert in-memory mutation so a cancelled conflict leaves model clean
$this -> application -> refresh ();
return false ;
}
} else {
$this -> forceSaveDomains = false ;
}
$warning = sslipDomainWarning ( $domainString ? ? '' );
if ( $warning ) {
$this -> dispatch ( 'warning' , __ ( 'warning.sslipdomain' ));
}
$this -> application -> save ();
2026-09-02 17:39:19 +00:00
if ( $this -> isCompose && ( $this -> application -> domain_port_overrides ? ? null ) !== $intendedComposeOverrides ) {
$this -> application -> domain_port_overrides = $intendedComposeOverrides ;
$this -> application -> save ();
}
2026-08-03 21:11:54 +00:00
$this -> resetDefaultLabels ();
$this -> dispatch ( 'configurationChanged' );
return true ;
}
2026-09-02 17:39:19 +00:00
/**
* @ param Collection < int , string > $previousServiceUrls
* @ param array < string , int >| null $incomingOverrides
* @ return array < string , int >| null
*/
protected function mergeComposeDomainPortOverrides (
Collection $previousServiceUrls ,
? string $newDomainString ,
? array $incomingOverrides ,
) : ? array {
$merged = $this -> application -> domain_port_overrides ? ? [];
$newCanonical = collect ( $this -> splitDomains ( $newDomainString ))
-> map ( fn ( string $url ) : string => DomainPortOverrides :: withoutPort ( $url ))
-> all ();
foreach ( $previousServiceUrls as $url ) {
$canonical = DomainPortOverrides :: withoutPort ( $url );
if ( ! in_array ( $canonical , $newCanonical , true )) {
unset ( $merged [ $canonical ]);
}
}
foreach ( $incomingOverrides ? ? [] as $url => $port ) {
$merged [ $url ] = ( int ) $port ;
}
return $merged ? : null ;
}
2026-08-03 21:11:54 +00:00
protected function resetDefaultLabels () : void
{
try {
if ( ! $this -> application -> settings -> is_container_label_readonly_enabled ) {
return ;
}
$customLabels = str ( implode ( '|coolify|' , generateLabelsApplication ( $this -> application ))) -> replace ( '|coolify|' , " \n " );
$this -> application -> custom_labels = base64_encode ( $customLabels );
$this -> application -> save ();
$this -> dispatch ( 'configurationChanged' );
} catch ( \Throwable $e ) {
handleError ( $e , $this );
}
}
public function render ()
{
return view ( 'livewire.project.application.domains' );
}
}