map(fn ($d) => trim($d)) ->filter() ->count(); } function isValidDomainUrl(string $url): bool { $components = parse_url($url); if ($components === false) { return false; } $scheme = $components['scheme'] ?? ''; $host = $components['host'] ?? ''; if (! in_array(strtolower($scheme), ['http', 'https'], true) || $host === '') { return false; } $urlToValidate = $scheme.'://'; if (isset($components['user'])) { $urlToValidate .= $components['user']; if (isset($components['pass'])) { $urlToValidate .= ':'.$components['pass']; } $urlToValidate .= '@'; } $urlToValidate .= str_replace('_', '-', $host); if (isset($components['port'])) { $urlToValidate .= ':'.$components['port']; } if (isset($components['path'])) { $urlToValidate .= $components['path']; } if (isset($components['query'])) { $urlToValidate .= '?'.$components['query']; } if (isset($components['fragment'])) { $urlToValidate .= '#'.$components['fragment']; } return filter_var($urlToValidate, FILTER_VALIDATE_URL) !== false; } function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null) { $conflicts = []; // Get the current team for filtering $currentTeam = null; if ($resource) { $currentTeam = $resource->team(); } if ($resource) { if ($resource->getMorphClass() === Application::class && $resource->build_pack === 'dockercompose') { $domains = data_get(json_decode($resource->docker_compose_domains, true), '*.domain'); $domains = collect($domains); } else { $domains = collect($resource->fqdns); } } elseif ($domain) { $domains = collect([$domain]); } else { return ['conflicts' => [], 'hasConflicts' => false]; } $domains = $domains->map(function ($domain) { if (str($domain)->endsWith('/')) { $domain = str($domain)->beforeLast('/'); } return str($domain); }); // Filter applications by team if we have a current team $appsQuery = Application::query(); if ($currentTeam) { $appsQuery = $appsQuery->whereHas('environment.project', function ($query) use ($currentTeam) { $query->where('team_id', $currentTeam->id); }); } $apps = $appsQuery->get(); foreach ($apps as $app) { $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== ''); foreach ($list_of_domains as $domain) { if (str($domain)->endsWith('/')) { $domain = str($domain)->beforeLast('/'); } $naked_domain = str($domain)->value(); if ($domains->contains($naked_domain)) { if (data_get($resource, 'uuid')) { if ($resource->uuid !== $app->uuid) { $conflicts[] = [ 'domain' => $naked_domain, 'resource_name' => $app->name, 'resource_link' => $app->link(), 'resource_type' => 'application', 'message' => "Domain $naked_domain is already in use by application '{$app->name}'", ]; } } elseif ($domain) { $conflicts[] = [ 'domain' => $naked_domain, 'resource_name' => $app->name, 'resource_link' => $app->link(), 'resource_type' => 'application', 'message' => "Domain $naked_domain is already in use by application '{$app->name}'", ]; } } } } // Filter service applications by team if we have a current team $serviceAppsQuery = ServiceApplication::query(); if ($currentTeam) { $serviceAppsQuery = $serviceAppsQuery->whereHas('service.environment.project', function ($query) use ($currentTeam) { $query->where('team_id', $currentTeam->id); }); } $apps = $serviceAppsQuery->get(); foreach ($apps as $app) { $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== ''); foreach ($list_of_domains as $domain) { if (str($domain)->endsWith('/')) { $domain = str($domain)->beforeLast('/'); } $naked_domain = str($domain)->value(); if ($domains->contains($naked_domain)) { if (data_get($resource, 'uuid')) { if ($resource->uuid !== $app->uuid) { $conflicts[] = [ 'domain' => $naked_domain, 'resource_name' => $app->service->name, 'resource_link' => $app->service->link(), 'resource_type' => 'service', 'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'", ]; } } elseif ($domain) { $conflicts[] = [ 'domain' => $naked_domain, 'resource_name' => $app->service->name, 'resource_link' => $app->service->link(), 'resource_type' => 'service', 'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'", ]; } } } } if ($resource) { $settings = instanceSettings(); if (data_get($settings, 'fqdn')) { $domain = data_get($settings, 'fqdn'); if (str($domain)->endsWith('/')) { $domain = str($domain)->beforeLast('/'); } $naked_domain = str($domain)->value(); if ($domains->contains($naked_domain)) { $conflicts[] = [ 'domain' => $naked_domain, 'resource_name' => 'Coolify Instance', 'resource_link' => '#', 'resource_type' => 'instance', 'message' => "Domain $naked_domain is already in use by this Coolify instance", ]; } } } return [ 'conflicts' => $conflicts, 'hasConflicts' => count($conflicts) > 0, ]; } function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $teamId = null, ?string $uuid = null) { $conflicts = []; if (is_null($teamId)) { return ['error' => 'Team ID is required.']; } if (is_array($domains)) { $domains = collect($domains); } $domains = $domains->map(function ($domain) { if (str($domain)->endsWith('/')) { $domain = str($domain)->beforeLast('/'); } return str($domain); }); $applications = Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid', 'name', 'id', 'docker_compose_domains', 'build_pack']); $serviceApplications = ServiceApplication::ownedByCurrentTeamAPI($teamId)->with('service:id,name')->get(['fqdn', 'uuid', 'id', 'service_id']); if ($uuid) { $applications = $applications->filter(fn ($app) => $app->uuid !== $uuid); $serviceApplications = $serviceApplications->filter(fn ($app) => $app->uuid !== $uuid); } foreach ($applications as $app) { if (! is_null($app->fqdn)) { $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== ''); foreach ($list_of_domains as $domain) { if (str($domain)->endsWith('/')) { $domain = str($domain)->beforeLast('/'); } $naked_domain = str($domain)->value(); if ($domains->contains($naked_domain)) { $conflicts[] = [ 'domain' => $naked_domain, 'resource_name' => $app->name, 'resource_uuid' => $app->uuid, 'resource_type' => 'application', 'message' => "Domain $naked_domain is already in use by application '{$app->name}'", ]; } } } if ($app->build_pack === 'dockercompose' && ! empty($app->docker_compose_domains)) { $dockerComposeDomains = json_decode($app->docker_compose_domains, true); if (is_array($dockerComposeDomains)) { foreach ($dockerComposeDomains as $serviceName => $domainConfig) { $domainValue = data_get($domainConfig, 'domain'); if (empty($domainValue)) { continue; } $list_of_domains = collect(explode(',', $domainValue))->filter(fn ($fqdn) => $fqdn !== ''); foreach ($list_of_domains as $domain) { if (str($domain)->endsWith('/')) { $domain = str($domain)->beforeLast('/'); } $naked_domain = str($domain)->value(); if ($domains->contains($naked_domain)) { $conflicts[] = [ 'domain' => $naked_domain, 'resource_name' => $app->name, 'resource_uuid' => $app->uuid, 'resource_type' => 'application', 'service_name' => $serviceName, 'message' => "Domain $naked_domain is already in use by application '{$app->name}' (service: {$serviceName})", ]; } } } } } } foreach ($serviceApplications as $app) { if (str($app->fqdn)->isEmpty()) { continue; } $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== ''); foreach ($list_of_domains as $domain) { if (str($domain)->endsWith('/')) { $domain = str($domain)->beforeLast('/'); } $naked_domain = str($domain)->value(); if ($domains->contains($naked_domain)) { $conflicts[] = [ 'domain' => $naked_domain, 'resource_name' => $app->service->name ?? 'Unknown Service', 'resource_uuid' => $app->uuid, 'resource_type' => 'service', 'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'", ]; } } } // Check instance-level domain $settings = instanceSettings(); if (data_get($settings, 'fqdn')) { $domain = data_get($settings, 'fqdn'); if (str($domain)->endsWith('/')) { $domain = str($domain)->beforeLast('/'); } $naked_domain = str($domain)->value(); if ($domains->contains($naked_domain)) { $conflicts[] = [ 'domain' => $naked_domain, 'resource_name' => 'Coolify Instance', 'resource_uuid' => null, 'resource_type' => 'instance', 'message' => "Domain $naked_domain is already in use by this Coolify instance", ]; } } return [ 'conflicts' => $conflicts, 'hasConflicts' => count($conflicts) > 0, ]; } /** * Normalize a compose service name the way Coolify historically did for env var keys * (hyphens and dots → underscores). Used for comparison and SERVICE_* env names only — * docker_compose_domains keys should use the original compose service name. */ function normalizeComposeServiceName(string $serviceName): string { return str($serviceName)->replace('-', '_')->replace('.', '_')->toString(); } /** * Resolve a candidate key (original or legacy-normalized) to the original compose service name. * * @param iterable $serviceNames */ function findComposeServiceName(string $candidate, iterable $serviceNames): ?string { $names = collect($serviceNames)->map(fn ($name) => (string) $name)->values(); if ($names->containsStrict($candidate)) { return $candidate; } $normalized = normalizeComposeServiceName($candidate); $matches = $names->filter( fn ($name) => normalizeComposeServiceName($name) === $normalized )->values(); return $matches->count() === 1 ? $matches->first() : null; } /** * Collapse domain-map keys that only differ by hyphen/dot/underscore into one preferred name. * Prefers a key that still has `-` or `.` over a fully underscore-normalized twin. * * @param iterable $domainKeys * @return list */ function preferredComposeServiceNamesFromDomainKeys(iterable $domainKeys): array { $groups = []; foreach ($domainKeys as $key) { $key = (string) $key; $groups[normalizeComposeServiceName($key)][] = $key; } $preferred = []; foreach ($groups as $normalized => $keys) { $keys = array_values(array_unique($keys)); $withSeparators = array_values(array_filter( $keys, fn (string $key) => $key !== $normalized )); $preferred[] = $withSeparators[0] ?? $keys[0]; } return $preferred; } /** * Read domain string for a compose service from docker_compose_domains. * Prefers a filled domain on the requested key; falls back to any filled twin * (legacy underscore keys). Blank entries do not shadow filled twins. * Uses collection key access (not dotted data_get) so names like "api.test" work. * * @param array|Collection $domains */ function getComposeServiceDomainString(array|Collection $domains, string $serviceName): ?string { $domains = collect($domains); $normalized = normalizeComposeServiceName($serviceName); $matches = []; foreach ($domains as $key => $entry) { $key = (string) $key; if ($key !== $serviceName && $key !== $normalized && normalizeComposeServiceName($key) !== $normalized) { continue; } $matches[] = [ 'key' => $key, 'domain' => composeDomainEntryString($entry), 'is_requested' => $key === $serviceName, ]; } if ($matches === []) { return null; } $filled = array_values(array_filter( $matches, fn (array $match) => filled($match['domain']) )); if ($filled !== []) { foreach ($filled as $match) { if ($match['is_requested']) { return $match['domain']; } } return $filled[0]['domain']; } foreach ($matches as $match) { if ($match['is_requested']) { return $match['domain']; } } return $matches[0]['domain']; } function composeDomainEntryString(mixed $entry): ?string { if (is_object($entry)) { $entry = (array) $entry; } if (! is_array($entry)) { return is_string($entry) ? $entry : null; } $domain = $entry['domain'] ?? null; return is_string($domain) ? $domain : null; } /** * Choose which domain string wins when merging twin compose domain keys. * Filled values always beat blanks; when both are filled, prefer the canonical key. */ function preferComposeDomainValue( mixed $existingDomain, bool $existingIsCanonical, mixed $incomingDomain, bool $incomingIsCanonical, ): mixed { $existingFilled = filled($existingDomain); $incomingFilled = filled($incomingDomain); if ($existingFilled && ! $incomingFilled) { return $existingDomain; } if ($incomingFilled && ! $existingFilled) { return $incomingDomain; } if ($existingFilled && $incomingFilled) { if ($incomingIsCanonical && ! $existingIsCanonical) { return $incomingDomain; } return $existingDomain; } // Both blank: keep canonical slot when possible. if ($incomingIsCanonical) { return $incomingDomain; } return $existingDomain; } /** * Rekey docker_compose_domains to original compose service names. * Merges legacy underscore/dot twin keys onto the canonical compose name. * Never lets an empty canonical key wipe a filled legacy twin. * * @param array|Collection $domains * @param iterable $serviceNames * @return array */ function rekeyComposeDomainsToServiceNames(array|Collection $domains, iterable $serviceNames): array { $domains = collect($domains); $serviceNames = collect($serviceNames)->map(fn ($name) => (string) $name)->values(); $rekeyed = []; $canonicalDomainSources = []; foreach ($domains as $key => $value) { $key = (string) $key; $original = findComposeServiceName($key, $serviceNames) ?? $key; $isCanonical = $key === $original; if (is_object($value)) { $value = (array) $value; } if (! is_array($value)) { $value = ['domain' => $value]; } if (! isset($rekeyed[$original])) { $rekeyed[$original] = $value; $canonicalDomainSources[$original] = $isCanonical; continue; } $existingDomain = $rekeyed[$original]['domain'] ?? null; $incomingDomain = $value['domain'] ?? null; $rekeyed[$original] = array_merge($rekeyed[$original], $value); $rekeyed[$original]['domain'] = preferComposeDomainValue( $existingDomain, $canonicalDomainSources[$original], $incomingDomain, $isCanonical, ); $canonicalDomainSources[$original] = $canonicalDomainSources[$original] || $isCanonical; } return $rekeyed; } /** * Resolve a SERVICE_FQDN_ / SERVICE_URL_ env fragment to a ServiceApplication by compose name. * Handles legacy underscore fragments and dotted compose names (api.test vs API_TEST). */ function findServiceApplicationForEnvName(Service $resource, string $envServiceName): ?ServiceApplication { $names = $resource->applications()->pluck('name'); $resolved = findComposeServiceName($envServiceName, $names) ?? findComposeServiceName(str($envServiceName)->replace('_', '-')->toString(), $names); if ($resolved === null) { return null; } return $resource->applications()->where('name', $resolved)->first(); } /** * Put/update a service domain entry under the original compose service name, * removing legacy twin keys that resolve unambiguously to the same name. * * @param array|Collection $domains * @param iterable $serviceNames * @return array */ function putComposeServiceDomain( array|Collection $domains, string $serviceName, ?string $domainString, iterable $serviceNames = [], ): array { $domains = collect($domains)->all(); $names = collect($serviceNames)->map(fn ($name) => (string) $name); $storageKey = findComposeServiceName($serviceName, $names) ?? $serviceName; $merged = ['domain' => $domainString]; foreach (array_keys($domains) as $key) { $key = (string) $key; if ($key !== $storageKey && findComposeServiceName($key, $names) !== $storageKey) { continue; } $existing = $domains[$key]; if (is_object($existing)) { $existing = (array) $existing; } if (is_array($existing)) { $merged = array_merge($existing, $merged); } if ($key !== $storageKey) { unset($domains[$key]); } } $domains[$storageKey] = $merged; return $domains; }