mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 02:27:57 +00:00
607 lines
20 KiB
PHP
607 lines
20 KiB
PHP
<?php
|
|
|
|
use App\Models\Application;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceApplication;
|
|
use Illuminate\Support\Collection;
|
|
|
|
/**
|
|
* Count domains in a comma-separated FQDN string (e.g. "https://a.com,https://b.com").
|
|
*/
|
|
function countDomains(?string $fqdn): int
|
|
{
|
|
if (! filled($fqdn)) {
|
|
return 0;
|
|
}
|
|
|
|
return collect(explode(',', $fqdn))
|
|
->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<int|string, mixed> $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<int|string, mixed> $domainKeys
|
|
* @return list<string>
|
|
*/
|
|
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<string, mixed>|Collection<string, mixed> $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<string, mixed>|Collection<string, mixed> $domains
|
|
* @param iterable<int|string, mixed> $serviceNames
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<string, mixed>|Collection<string, mixed> $domains
|
|
* @param iterable<int|string, mixed> $serviceNames
|
|
* @return array<string, mixed>
|
|
*/
|
|
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;
|
|
}
|