From f4acf7ca108de1fc21093376bf305b18bedd73a2 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:21:18 +0100 Subject: [PATCH 001/110] refactor(api): application urls validation - rename fqdn to urls as that is what it actually is - improve URL validation to allow urls without a TLD - improve error messages to make it clear that URLs are needed - improve code by combining some actions --- .../Api/ApplicationsController.php | 48 ++++++++++++------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 60fd45ef4..25b98c465 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2411,18 +2411,24 @@ public function update_by_uuid(Request $request) $requestHasDomains = $request->has('domains'); if ($requestHasDomains && $server->isProxyShouldRun()) { $uuid = $request->uuid; - $fqdn = $request->domains; - $fqdn = str($fqdn)->replaceEnd(',', '')->trim(); - $fqdn = str($fqdn)->replaceStart(',', '')->trim(); + $urls = $request->domains; + $urls = str($urls)->replaceStart(',', '')->replaceEnd(',', '')->trim(); $errors = []; - $fqdn = str($fqdn)->trim()->explode(',')->map(function ($domain) use (&$errors) { - $domain = trim($domain); - if (filter_var($domain, FILTER_VALIDATE_URL) === false || ! preg_match('/^https?:\/\/[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,}/', $domain)) { - $errors[] = 'Invalid domain: '.$domain; + $urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) { + $url = trim($url); + if (! filter_var($url, FILTER_VALIDATE_URL)) { + $errors[] = 'Invalid URL: '.$url; + + return $url; + } + $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; + if (! in_array(strtolower($scheme), ['http', 'https'])) { + $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; } - return $domain; + return str($url)->lower(); }); + if (count($errors) > 0) { return response()->json([ 'message' => 'Validation failed.', @@ -2430,7 +2436,7 @@ public function update_by_uuid(Request $request) ], 422); } // Check for domain conflicts - $result = checkIfDomainIsAlreadyUsedViaAPI($fqdn, $teamId, $uuid); + $result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid); if (isset($result['error'])) { return response()->json([ 'message' => 'Validation failed.', @@ -3626,17 +3632,23 @@ private function validateDataApplications(Request $request, Server $server) } if ($request->has('domains') && $server->isProxyShouldRun()) { $uuid = $request->uuid; - $fqdn = $request->domains; - $fqdn = str($fqdn)->replaceEnd(',', '')->trim(); - $fqdn = str($fqdn)->replaceStart(',', '')->trim(); + $urls = $request->domains; + $urls = str($urls)->replaceEnd(',', '')->trim(); + $urls = str($urls)->replaceStart(',', '')->trim(); $errors = []; - $fqdn = str($fqdn)->trim()->explode(',')->map(function ($domain) use (&$errors) { - $domain = trim($domain); - if (filter_var($domain, FILTER_VALIDATE_URL) === false) { - $errors[] = 'Invalid domain: '.$domain; + $urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) { + $url = trim($url); + if (! filter_var($url, FILTER_VALIDATE_URL)) { + $errors[] = 'Invalid URL: '.$url; + + return str($url)->lower(); + } + $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; + if (! in_array(strtolower($scheme), ['http', 'https'])) { + $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; } - return str($domain)->lower(); + return str($url)->lower(); }); if (count($errors) > 0) { return response()->json([ @@ -3645,7 +3657,7 @@ private function validateDataApplications(Request $request, Server $server) ], 422); } // Check for domain conflicts - $result = checkIfDomainIsAlreadyUsedViaAPI($fqdn, $teamId, $uuid); + $result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid); if (isset($result['error'])) { return response()->json([ 'message' => 'Validation failed.', From c66b6490e65639300d1584fef83ded90c2eace93 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:24:27 +0100 Subject: [PATCH 002/110] docs(api): improve domains API docs --- app/Http/Controllers/Api/ApplicationsController.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 25b98c465..d655a9d1d 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -153,7 +153,7 @@ public function applications(Request $request) 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], - 'domains' => ['type' => 'string', 'description' => 'The application domains.'], + 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], 'git_commit_sha' => ['type' => 'string', 'description' => 'The git commit SHA.'], 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], @@ -315,7 +315,7 @@ public function create_public_application(Request $request) 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], - 'domains' => ['type' => 'string', 'description' => 'The application domains.'], + 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], 'git_commit_sha' => ['type' => 'string', 'description' => 'The git commit SHA.'], 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], @@ -476,7 +476,7 @@ public function create_private_gh_app_application(Request $request) 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], - 'domains' => ['type' => 'string', 'description' => 'The application domains.'], + 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], 'git_commit_sha' => ['type' => 'string', 'description' => 'The git commit SHA.'], 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], @@ -635,7 +635,7 @@ public function create_private_deploy_key_application(Request $request) 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], - 'domains' => ['type' => 'string', 'description' => 'The application domains.'], + 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], 'ports_mappings' => ['type' => 'string', 'description' => 'The ports mappings.'], @@ -771,7 +771,7 @@ public function create_dockerfile_application(Request $request) 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], - 'domains' => ['type' => 'string', 'description' => 'The application domains.'], + 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], 'ports_mappings' => ['type' => 'string', 'description' => 'The ports mappings.'], 'health_check_enabled' => ['type' => 'boolean', 'description' => 'Health check enabled.'], 'health_check_path' => ['type' => 'string', 'description' => 'Health check path.'], @@ -2150,7 +2150,7 @@ public function delete_by_uuid(Request $request) 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], - 'domains' => ['type' => 'string', 'description' => 'The application domains.'], + 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], 'git_commit_sha' => ['type' => 'string', 'description' => 'The git commit SHA.'], 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], From 754448d9d447c8bc333dc61daf49bf7bdde46ce0 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:35:37 +0100 Subject: [PATCH 003/110] feat(api): improve docker_compose_domains - add url conflict checking and force_domain_override support - refactor docker_compose_domains URL validation function --- .../Api/ApplicationsController.php | 268 +++++++++++------- 1 file changed, 172 insertions(+), 96 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index d655a9d1d..24e8394a4 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1130,39 +1130,58 @@ private function create_application(Request $request, $type) $dockerComposeDomainsJson = collect(); if ($request->has('docker_compose_domains')) { $dockerComposeDomains = collect($request->docker_compose_domains); - $domainErrors = []; - foreach ($dockerComposeDomains as $index => $item) { + // Collect all URLs from all docker_compose_domains items + $urls = $dockerComposeDomains->flatMap(function ($item) { $domainValue = data_get($item, 'domain'); - if (filled($domainValue)) { - $urls = str($domainValue)->replaceStart(',', '')->replaceEnd(',', '')->trim(); - str($urls)->explode(',')->each(function ($url) use (&$domainErrors) { - $url = trim($url); - if (empty($url)) { - return; - } - if (! filter_var($url, FILTER_VALIDATE_URL)) { - $domainErrors[] = "Invalid URL: {$url}"; - - return; - } - $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; - if (! in_array(strtolower($scheme), ['http', 'https'])) { - $domainErrors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; - } - }); + if (blank($domainValue)) { + return []; } - } - if (! empty($domainErrors)) { + return str($domainValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter(); + }); + + $errors = []; + $urls = $urls->map(function ($url) use (&$errors) { + if (! filter_var($url, FILTER_VALIDATE_URL)) { + $errors[] = "Invalid URL: {$url}"; + + return $url; + } + $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; + if (! in_array(strtolower($scheme), ['http', 'https'])) { + $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; + } + + return $url; + }); + + if (count($errors) > 0) { return response()->json([ 'message' => 'Validation failed.', - 'errors' => [ - 'docker_compose_domains' => $domainErrors, - ], + 'errors' => ['docker_compose_domains' => $errors], ], 422); } + // Check for domain conflicts + if ($urls->isNotEmpty()) { + $result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId); + if (isset($result['error'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_compose_domains' => $result['error']], + ], 422); + } + + if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $result['conflicts'], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } + } + $dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) { $dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]); }); @@ -1319,39 +1338,58 @@ private function create_application(Request $request, $type) $dockerComposeDomainsJson = collect(); if ($request->has('docker_compose_domains')) { $dockerComposeDomains = collect($request->docker_compose_domains); - $domainErrors = []; - foreach ($dockerComposeDomains as $index => $item) { + // Collect all URLs from all docker_compose_domains items + $urls = $dockerComposeDomains->flatMap(function ($item) { $domainValue = data_get($item, 'domain'); - if (filled($domainValue)) { - $urls = str($domainValue)->replaceStart(',', '')->replaceEnd(',', '')->trim(); - str($urls)->explode(',')->each(function ($url) use (&$domainErrors) { - $url = trim($url); - if (empty($url)) { - return; - } - if (! filter_var($url, FILTER_VALIDATE_URL)) { - $domainErrors[] = "Invalid URL: {$url}"; - - return; - } - $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; - if (! in_array(strtolower($scheme), ['http', 'https'])) { - $domainErrors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; - } - }); + if (blank($domainValue)) { + return []; } - } - if (! empty($domainErrors)) { + return str($domainValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter(); + }); + + $errors = []; + $urls = $urls->map(function ($url) use (&$errors) { + if (! filter_var($url, FILTER_VALIDATE_URL)) { + $errors[] = "Invalid URL: {$url}"; + + return $url; + } + $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; + if (! in_array(strtolower($scheme), ['http', 'https'])) { + $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; + } + + return $url; + }); + + if (count($errors) > 0) { return response()->json([ 'message' => 'Validation failed.', - 'errors' => [ - 'docker_compose_domains' => $domainErrors, - ], + 'errors' => ['docker_compose_domains' => $errors], ], 422); } + // Check for domain conflicts + if ($urls->isNotEmpty()) { + $result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId); + if (isset($result['error'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_compose_domains' => $result['error']], + ], 422); + } + + if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $result['conflicts'], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } + } + $dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) { $dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]); }); @@ -1476,39 +1514,58 @@ private function create_application(Request $request, $type) $dockerComposeDomainsJson = collect(); if ($request->has('docker_compose_domains')) { $dockerComposeDomains = collect($request->docker_compose_domains); - $domainErrors = []; - foreach ($dockerComposeDomains as $index => $item) { + // Collect all URLs from all docker_compose_domains items + $urls = $dockerComposeDomains->flatMap(function ($item) { $domainValue = data_get($item, 'domain'); - if (filled($domainValue)) { - $urls = str($domainValue)->replaceStart(',', '')->replaceEnd(',', '')->trim(); - str($urls)->explode(',')->each(function ($url) use (&$domainErrors) { - $url = trim($url); - if (empty($url)) { - return; - } - if (! filter_var($url, FILTER_VALIDATE_URL)) { - $domainErrors[] = "Invalid URL: {$url}"; - - return; - } - $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; - if (! in_array(strtolower($scheme), ['http', 'https'])) { - $domainErrors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; - } - }); + if (blank($domainValue)) { + return []; } - } - if (! empty($domainErrors)) { + return str($domainValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter(); + }); + + $errors = []; + $urls = $urls->map(function ($url) use (&$errors) { + if (! filter_var($url, FILTER_VALIDATE_URL)) { + $errors[] = "Invalid URL: {$url}"; + + return $url; + } + $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; + if (! in_array(strtolower($scheme), ['http', 'https'])) { + $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; + } + + return $url; + }); + + if (count($errors) > 0) { return response()->json([ 'message' => 'Validation failed.', - 'errors' => [ - 'docker_compose_domains' => $domainErrors, - ], + 'errors' => ['docker_compose_domains' => $errors], ], 422); } + // Check for domain conflicts + if ($urls->isNotEmpty()) { + $result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId); + if (isset($result['error'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_compose_domains' => $result['error']], + ], 422); + } + + if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $result['conflicts'], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } + } + $dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) { $dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]); }); @@ -2466,39 +2523,58 @@ public function update_by_uuid(Request $request) } $dockerComposeDomains = collect($request->docker_compose_domains); - $domainErrors = []; - foreach ($dockerComposeDomains as $item) { + // Collect all URLs from all docker_compose_domains items + $urls = $dockerComposeDomains->flatMap(function ($item) { $domainValue = data_get($item, 'domain'); - if (filled($domainValue)) { - $urls = str($domainValue)->replaceStart(',', '')->replaceEnd(',', '')->trim(); - str($urls)->explode(',')->each(function ($url) use (&$domainErrors) { - $url = trim($url); - if (empty($url)) { - return; - } - if (! filter_var($url, FILTER_VALIDATE_URL)) { - $domainErrors[] = "Invalid URL: {$url}"; - - return; - } - $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; - if (! in_array(strtolower($scheme), ['http', 'https'])) { - $domainErrors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; - } - }); + if (blank($domainValue)) { + return []; } - } - if (! empty($domainErrors)) { + return str($domainValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter(); + }); + + $errors = []; + $urls = $urls->map(function ($url) use (&$errors) { + if (! filter_var($url, FILTER_VALIDATE_URL)) { + $errors[] = "Invalid URL: {$url}"; + + return $url; + } + $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; + if (! in_array(strtolower($scheme), ['http', 'https'])) { + $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; + } + + return $url; + }); + + if (count($errors) > 0) { return response()->json([ 'message' => 'Validation failed.', - 'errors' => [ - 'docker_compose_domains' => $domainErrors, - ], + 'errors' => ['docker_compose_domains' => $errors], ], 422); } + // Check for domain conflicts + if ($urls->isNotEmpty()) { + $result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $request->uuid); + if (isset($result['error'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_compose_domains' => $result['error']], + ], 422); + } + + if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $result['conflicts'], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } + } + $yaml = Yaml::parse($application->docker_compose_raw); $services = data_get($yaml, 'services', []); $dockerComposeDomains->each(function ($domain) use ($services, $dockerComposeDomainsJson) { From 5f5c26d8417c1c5acd2746d400c2636b8a6e95ab Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:27:24 +0100 Subject: [PATCH 004/110] fix(api): check domain conflicts within the request --- .../Api/ApplicationsController.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 24e8394a4..ba8df932b 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1156,6 +1156,11 @@ private function create_application(Request $request, $type) return $url; }); + $duplicates = $urls->duplicates()->unique()->values(); + if ($duplicates->isNotEmpty() && ! $request->boolean('force_domain_override')) { + $errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()); + } + if (count($errors) > 0) { return response()->json([ 'message' => 'Validation failed.', @@ -1364,6 +1369,11 @@ private function create_application(Request $request, $type) return $url; }); + $duplicates = $urls->duplicates()->unique()->values(); + if ($duplicates->isNotEmpty() && ! $request->boolean('force_domain_override')) { + $errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()); + } + if (count($errors) > 0) { return response()->json([ 'message' => 'Validation failed.', @@ -1540,6 +1550,11 @@ private function create_application(Request $request, $type) return $url; }); + $duplicates = $urls->duplicates()->unique()->values(); + if ($duplicates->isNotEmpty() && ! $request->boolean('force_domain_override')) { + $errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()); + } + if (count($errors) > 0) { return response()->json([ 'message' => 'Validation failed.', @@ -2549,6 +2564,11 @@ public function update_by_uuid(Request $request) return $url; }); + $duplicates = $urls->duplicates()->unique()->values(); + if ($duplicates->isNotEmpty() && ! $request->boolean('force_domain_override')) { + $errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()); + } + if (count($errors) > 0) { return response()->json([ 'message' => 'Validation failed.', From fb5695941808db1eab3ad99a81fc71fb51da90cd Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:28:08 +0100 Subject: [PATCH 005/110] fix(api): include docker_compose_domains in domain conflict check --- bootstrap/helpers/domains.php | 63 +++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php index 5b665890c..ff77a78e2 100644 --- a/bootstrap/helpers/domains.php +++ b/bootstrap/helpers/domains.php @@ -158,8 +158,7 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te return str($domain); }); - // Check applications within the same team - $applications = Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid', 'name', 'id']); + $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) { @@ -168,23 +167,51 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te } foreach ($applications as $app) { - if (is_null($app->fqdn)) { - 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('/'); + 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}'", + ]; + } } - $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})", + ]; + } + } + } } } } From 8a1d76cd99121d4aa4bf57dc906a93beaee15ab6 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:48:11 +0100 Subject: [PATCH 006/110] fix(api): is_static and docker network missing - GitHub App and Private Deploy Key where missing is_static and connect_to_docker_network --- .../Api/ApplicationsController.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index ba8df932b..74542ac67 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1424,6 +1424,14 @@ private function create_application(Request $request, $type) $application->fqdn = generateUrl(server: $server, random: $application->uuid); $application->save(); } + if (isset($isStatic)) { + $application->settings->is_static = $isStatic; + $application->settings->save(); + } + if (isset($connectToDockerNetwork)) { + $application->settings->connect_to_docker_network = $connectToDockerNetwork; + $application->settings->save(); + } if (isset($useBuildServer)) { $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); @@ -1601,6 +1609,14 @@ private function create_application(Request $request, $type) $application->fqdn = generateUrl(server: $server, random: $application->uuid); $application->save(); } + if (isset($isStatic)) { + $application->settings->is_static = $isStatic; + $application->settings->save(); + } + if (isset($connectToDockerNetwork)) { + $application->settings->connect_to_docker_network = $connectToDockerNetwork; + $application->settings->save(); + } if (isset($useBuildServer)) { $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); @@ -1703,6 +1719,9 @@ private function create_application(Request $request, $type) if ($autogenerateDomain && blank($fqdn)) { $application->fqdn = generateUrl(server: $server, random: $application->uuid); $application->save(); + if (isset($connectToDockerNetwork)) { + $application->settings->connect_to_docker_network = $connectToDockerNetwork; + $application->settings->save(); } if (isset($useBuildServer)) { $application->settings->is_build_server_enabled = $useBuildServer; @@ -1805,6 +1824,9 @@ private function create_application(Request $request, $type) if ($autogenerateDomain && blank($fqdn)) { $application->fqdn = generateUrl(server: $server, random: $application->uuid); $application->save(); + if (isset($connectToDockerNetwork)) { + $application->settings->connect_to_docker_network = $connectToDockerNetwork; + $application->settings->save(); } if (isset($useBuildServer)) { $application->settings->is_build_server_enabled = $useBuildServer; From 6ca04b561373291b368bfccbe1b111fd2676c229 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:16:40 +0100 Subject: [PATCH 007/110] feat(api): add more allowed fields - added dockerfile_location as it is needed for Dockerfile deployments to work properly - added is_spa as it makes sense together with is_static - added is_auto_deploy_enabled and is_force_https_enabled --- .../Api/ApplicationsController.php | 89 ++++++++++++++++++- bootstrap/helpers/api.php | 8 ++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 74542ac67..143ba64d3 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -158,6 +158,9 @@ public function applications(Request $request) 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], 'is_static' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is static.'], + 'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'], + 'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'], + 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], 'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'], 'install_command' => ['type' => 'string', 'description' => 'The install command.'], 'build_command' => ['type' => 'string', 'description' => 'The build command.'], @@ -198,6 +201,7 @@ public function applications(Request $request) // 'github_app_uuid' => ['type' => 'string', 'description' => 'The Github App UUID.'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'], + 'dockerfile_location' => ['type' => 'string', 'description' => 'The Dockerfile location in the repository.'], 'docker_compose_location' => ['type' => 'string', 'description' => 'The Docker Compose location.'], 'docker_compose_custom_start_command' => ['type' => 'string', 'description' => 'The Docker Compose custom start command.'], 'docker_compose_custom_build_command' => ['type' => 'string', 'description' => 'The Docker Compose custom build command.'], @@ -320,6 +324,9 @@ public function create_public_application(Request $request) 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], 'is_static' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is static.'], + 'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'], + 'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'], + 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], 'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'], 'install_command' => ['type' => 'string', 'description' => 'The install command.'], 'build_command' => ['type' => 'string', 'description' => 'The build command.'], @@ -359,6 +366,7 @@ public function create_public_application(Request $request) 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'], + 'dockerfile_location' => ['type' => 'string', 'description' => 'The Dockerfile location in the repository'], 'docker_compose_location' => ['type' => 'string', 'description' => 'The Docker Compose location.'], 'docker_compose_custom_start_command' => ['type' => 'string', 'description' => 'The Docker Compose custom start command.'], 'docker_compose_custom_build_command' => ['type' => 'string', 'description' => 'The Docker Compose custom build command.'], @@ -481,6 +489,9 @@ public function create_private_gh_app_application(Request $request) 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], 'is_static' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is static.'], + 'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'], + 'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'], + 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], 'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'], 'install_command' => ['type' => 'string', 'description' => 'The install command.'], 'build_command' => ['type' => 'string', 'description' => 'The build command.'], @@ -520,6 +531,7 @@ public function create_private_gh_app_application(Request $request) 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'], + 'dockerfile_location' => ['type' => 'string', 'description' => 'The Dockerfile location in the repository.'], 'docker_compose_location' => ['type' => 'string', 'description' => 'The Docker Compose location.'], 'docker_compose_custom_start_command' => ['type' => 'string', 'description' => 'The Docker Compose custom start command.'], 'docker_compose_custom_build_command' => ['type' => 'string', 'description' => 'The Docker Compose custom build command.'], @@ -671,6 +683,7 @@ public function create_private_deploy_key_application(Request $request) 'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'], 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], + 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], @@ -804,6 +817,7 @@ public function create_dockerfile_application(Request $request) 'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'], 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], + 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], @@ -987,7 +1001,7 @@ private function create_application(Request $request, $type) if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled']; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled']; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -1030,6 +1044,9 @@ private function create_application(Request $request, $type) $githubAppUuid = $request->github_app_uuid; $useBuildServer = $request->use_build_server; $isStatic = $request->is_static; + $isSpa = $request->is_spa; + $isAutoDeployEnabled = $request->is_auto_deploy_enabled; + $isForceHttpsEnabled = $request->is_force_https_enabled; $connectToDockerNetwork = $request->connect_to_docker_network; $customNginxConfiguration = $request->custom_nginx_configuration; $isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true); @@ -1211,6 +1228,18 @@ private function create_application(Request $request, $type) $application->settings->is_static = $isStatic; $application->settings->save(); } + if (isset($isSpa)) { + $application->settings->is_spa = $isSpa; + $application->settings->save(); + } + if (isset($isAutoDeployEnabled)) { + $application->settings->is_auto_deploy_enabled = $isAutoDeployEnabled; + $application->settings->save(); + } + if (isset($isForceHttpsEnabled)) { + $application->settings->is_force_https_enabled = $isForceHttpsEnabled; + $application->settings->save(); + } if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); @@ -1428,6 +1457,18 @@ private function create_application(Request $request, $type) $application->settings->is_static = $isStatic; $application->settings->save(); } + if (isset($isSpa)) { + $application->settings->is_spa = $isSpa; + $application->settings->save(); + } + if (isset($isAutoDeployEnabled)) { + $application->settings->is_auto_deploy_enabled = $isAutoDeployEnabled; + $application->settings->save(); + } + if (isset($isForceHttpsEnabled)) { + $application->settings->is_force_https_enabled = $isForceHttpsEnabled; + $application->settings->save(); + } if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); @@ -1613,6 +1654,18 @@ private function create_application(Request $request, $type) $application->settings->is_static = $isStatic; $application->settings->save(); } + if (isset($isSpa)) { + $application->settings->is_spa = $isSpa; + $application->settings->save(); + } + if (isset($isAutoDeployEnabled)) { + $application->settings->is_auto_deploy_enabled = $isAutoDeployEnabled; + $application->settings->save(); + } + if (isset($isForceHttpsEnabled)) { + $application->settings->is_force_https_enabled = $isForceHttpsEnabled; + $application->settings->save(); + } if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); @@ -1719,6 +1772,11 @@ private function create_application(Request $request, $type) if ($autogenerateDomain && blank($fqdn)) { $application->fqdn = generateUrl(server: $server, random: $application->uuid); $application->save(); + } + if (isset($isForceHttpsEnabled)) { + $application->settings->is_force_https_enabled = $isForceHttpsEnabled; + $application->settings->save(); + } if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); @@ -1824,6 +1882,11 @@ private function create_application(Request $request, $type) if ($autogenerateDomain && blank($fqdn)) { $application->fqdn = generateUrl(server: $server, random: $application->uuid); $application->save(); + } + if (isset($isForceHttpsEnabled)) { + $application->settings->is_force_https_enabled = $isForceHttpsEnabled; + $application->settings->save(); + } if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); @@ -2249,6 +2312,9 @@ public function delete_by_uuid(Request $request) 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], 'is_static' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is static.'], + 'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'], + 'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'], + 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], 'install_command' => ['type' => 'string', 'description' => 'The install command.'], 'build_command' => ['type' => 'string', 'description' => 'The build command.'], 'start_command' => ['type' => 'string', 'description' => 'The start command.'], @@ -2287,6 +2353,7 @@ public function delete_by_uuid(Request $request) 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'], + 'dockerfile_location' => ['type' => 'string', 'description' => 'The Dockerfile location in the repository.'], 'docker_compose_location' => ['type' => 'string', 'description' => 'The Docker Compose location.'], 'docker_compose_custom_start_command' => ['type' => 'string', 'description' => 'The Docker Compose custom start command.'], 'docker_compose_custom_build_command' => ['type' => 'string', 'description' => 'The Docker Compose custom build command.'], @@ -2391,7 +2458,7 @@ public function update_by_uuid(Request $request) $this->authorize('update', $application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings','custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled']; + $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled']; $validationRules = [ 'name' => 'string|max:255', @@ -2629,6 +2696,9 @@ public function update_by_uuid(Request $request) } $instantDeploy = $request->instant_deploy; $isStatic = $request->is_static; + $isSpa = $request->is_spa; + $isAutoDeployEnabled = $request->is_auto_deploy_enabled; + $isForceHttpsEnabled = $request->is_force_https_enabled; $connectToDockerNetwork = $request->connect_to_docker_network; $useBuildServer = $request->use_build_server; $isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled'); @@ -2643,6 +2713,21 @@ public function update_by_uuid(Request $request) $application->settings->save(); } + if (isset($isSpa)) { + $application->settings->is_spa = $isSpa; + $application->settings->save(); + } + + if (isset($isAutoDeployEnabled)) { + $application->settings->is_auto_deploy_enabled = $isAutoDeployEnabled; + $application->settings->save(); + } + + if (isset($isForceHttpsEnabled)) { + $application->settings->is_force_https_enabled = $isForceHttpsEnabled; + $application->settings->save(); + } + if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 55cff42d0..c23f55c12 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -86,6 +86,9 @@ function sharedDataApplications() 'git_branch' => 'string', 'build_pack' => Rule::enum(BuildPackTypes::class), 'is_static' => 'boolean', + 'is_spa' => 'boolean', + 'is_auto_deploy_enabled' => 'boolean', + 'is_force_https_enabled' => 'boolean', 'static_image' => Rule::enum(StaticImageTypes::class), 'domains' => 'string', 'redirect' => Rule::enum(RedirectTypes::class), @@ -129,6 +132,7 @@ function sharedDataApplications() 'manual_webhook_secret_gitlab' => 'string|nullable', 'manual_webhook_secret_bitbucket' => 'string|nullable', 'manual_webhook_secret_gitea' => 'string|nullable', + 'dockerfile_location' => 'string|nullable', 'docker_compose_location' => 'string', 'docker_compose' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', @@ -177,6 +181,10 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('private_key_uuid'); $request->offsetUnset('use_build_server'); $request->offsetUnset('is_static'); + $request->offsetUnset('is_spa'); + $request->offsetUnset('is_auto_deploy_enabled'); + $request->offsetUnset('is_force_https_enabled'); + $request->offsetUnset('connect_to_docker_network'); $request->offsetUnset('force_domain_override'); $request->offsetUnset('autogenerate_domain'); $request->offsetUnset('is_container_label_escape_enabled'); From 161e0d2b0586711f75a41cc25380806bcd0b41df Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:37:02 +0100 Subject: [PATCH 008/110] chore(api): improve current request error message --- app/Http/Controllers/Api/ApplicationsController.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 143ba64d3..dfc67bfae 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1175,7 +1175,7 @@ private function create_application(Request $request, $type) $duplicates = $urls->duplicates()->unique()->values(); if ($duplicates->isNotEmpty() && ! $request->boolean('force_domain_override')) { - $errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()); + $errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()).' Use force_domain_override=true to proceed.'; } if (count($errors) > 0) { @@ -1400,7 +1400,7 @@ private function create_application(Request $request, $type) $duplicates = $urls->duplicates()->unique()->values(); if ($duplicates->isNotEmpty() && ! $request->boolean('force_domain_override')) { - $errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()); + $errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()).' Use force_domain_override=true to proceed. '; } if (count($errors) > 0) { @@ -1601,7 +1601,7 @@ private function create_application(Request $request, $type) $duplicates = $urls->duplicates()->unique()->values(); if ($duplicates->isNotEmpty() && ! $request->boolean('force_domain_override')) { - $errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()); + $errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()).' Use force_domain_override=true to proceed.'; } if (count($errors) > 0) { @@ -2655,7 +2655,7 @@ public function update_by_uuid(Request $request) $duplicates = $urls->duplicates()->unique()->values(); if ($duplicates->isNotEmpty() && ! $request->boolean('force_domain_override')) { - $errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()); + $errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()).' Use force_domain_override=true to proceed.'; } if (count($errors) > 0) { From e53c71908f46207b1b23d0df273ebdc149598e2a Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:12:49 +0100 Subject: [PATCH 009/110] fix(api): if domains field is empty clear the fqdn column - providing an empty string for `domains` allows the ability to remove all URLs from the domains field --- app/Http/Controllers/Api/ApplicationsController.php | 12 ++++++++++++ bootstrap/helpers/api.php | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index dfc67bfae..701c92d4d 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2577,6 +2577,12 @@ public function update_by_uuid(Request $request) $errors = []; $urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) { $url = trim($url); + + // If "domains" is empty clear all URLs from the fqdn column + if (blank($url)) { + return null; + } + if (! filter_var($url, FILTER_VALIDATE_URL)) { $errors[] = 'Invalid URL: '.$url; @@ -3841,6 +3847,12 @@ private function validateDataApplications(Request $request, Server $server) $errors = []; $urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) { $url = trim($url); + + // If "domains" is empty clear all URLs from the fqdn column + if (blank($url)) { + return null; + } + if (! filter_var($url, FILTER_VALIDATE_URL)) { $errors[] = 'Invalid URL: '.$url; diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index c23f55c12..d5c2c996b 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -90,7 +90,7 @@ function sharedDataApplications() 'is_auto_deploy_enabled' => 'boolean', 'is_force_https_enabled' => 'boolean', 'static_image' => Rule::enum(StaticImageTypes::class), - 'domains' => 'string', + 'domains' => 'string|nullable', 'redirect' => Rule::enum(RedirectTypes::class), 'git_commit_sha' => 'string', 'docker_registry_image_name' => 'string|nullable', From a05c19855457e2571333222c7ed14ccaf4c3edab Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:44:27 +0100 Subject: [PATCH 010/110] chore(api): update openapi files --- openapi.json | 84 ++++++++++++++++++++++++++++++++++++++++++++++++---- openapi.yaml | 66 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 138 insertions(+), 12 deletions(-) diff --git a/openapi.json b/openapi.json index a94ef79b1..7bb1ff8f0 100644 --- a/openapi.json +++ b/openapi.json @@ -135,7 +135,7 @@ }, "domains": { "type": "string", - "description": "The application domains." + "description": "The application URLs in a comma-separated list." }, "git_commit_sha": { "type": "string", @@ -153,6 +153,18 @@ "type": "boolean", "description": "The flag to indicate if the application is static." }, + "is_spa": { + "type": "boolean", + "description": "The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true." + }, + "is_auto_deploy_enabled": { + "type": "boolean", + "description": "The flag to indicate if auto-deploy is enabled on git push. Defaults to true." + }, + "is_force_https_enabled": { + "type": "boolean", + "description": "The flag to indicate if HTTPS is forced. Defaults to true." + }, "static_image": { "type": "string", "enum": [ @@ -322,6 +334,10 @@ "type": "string", "description": "The Dockerfile content." }, + "dockerfile_location": { + "type": "string", + "description": "The Dockerfile location in the repository." + }, "docker_compose_location": { "type": "string", "description": "The Docker Compose location." @@ -564,7 +580,7 @@ }, "domains": { "type": "string", - "description": "The application domains." + "description": "The application URLs in a comma-separated list." }, "git_commit_sha": { "type": "string", @@ -582,6 +598,18 @@ "type": "boolean", "description": "The flag to indicate if the application is static." }, + "is_spa": { + "type": "boolean", + "description": "The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true." + }, + "is_auto_deploy_enabled": { + "type": "boolean", + "description": "The flag to indicate if auto-deploy is enabled on git push. Defaults to true." + }, + "is_force_https_enabled": { + "type": "boolean", + "description": "The flag to indicate if HTTPS is forced. Defaults to true." + }, "static_image": { "type": "string", "enum": [ @@ -751,6 +779,10 @@ "type": "string", "description": "The Dockerfile content." }, + "dockerfile_location": { + "type": "string", + "description": "The Dockerfile location in the repository" + }, "docker_compose_location": { "type": "string", "description": "The Docker Compose location." @@ -993,7 +1025,7 @@ }, "domains": { "type": "string", - "description": "The application domains." + "description": "The application URLs in a comma-separated list." }, "git_commit_sha": { "type": "string", @@ -1011,6 +1043,18 @@ "type": "boolean", "description": "The flag to indicate if the application is static." }, + "is_spa": { + "type": "boolean", + "description": "The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true." + }, + "is_auto_deploy_enabled": { + "type": "boolean", + "description": "The flag to indicate if auto-deploy is enabled on git push. Defaults to true." + }, + "is_force_https_enabled": { + "type": "boolean", + "description": "The flag to indicate if HTTPS is forced. Defaults to true." + }, "static_image": { "type": "string", "enum": [ @@ -1180,6 +1224,10 @@ "type": "string", "description": "The Dockerfile content." }, + "dockerfile_location": { + "type": "string", + "description": "The Dockerfile location in the repository." + }, "docker_compose_location": { "type": "string", "description": "The Docker Compose location." @@ -1410,7 +1458,7 @@ }, "domains": { "type": "string", - "description": "The application domains." + "description": "The application URLs in a comma-separated list." }, "docker_registry_image_name": { "type": "string", @@ -1562,6 +1610,10 @@ "type": "boolean", "description": "The flag to indicate if the application should be deployed instantly." }, + "is_force_https_enabled": { + "type": "boolean", + "description": "The flag to indicate if HTTPS is forced. Defaults to true." + }, "use_build_server": { "type": "boolean", "nullable": true, @@ -1754,7 +1806,7 @@ }, "domains": { "type": "string", - "description": "The application domains." + "description": "The application URLs in a comma-separated list." }, "ports_mappings": { "type": "string", @@ -1894,6 +1946,10 @@ "type": "boolean", "description": "The flag to indicate if the application should be deployed instantly." }, + "is_force_https_enabled": { + "type": "boolean", + "description": "The flag to indicate if HTTPS is forced. Defaults to true." + }, "use_build_server": { "type": "boolean", "nullable": true, @@ -2402,7 +2458,7 @@ }, "domains": { "type": "string", - "description": "The application domains." + "description": "The application URLs in a comma-separated list." }, "git_commit_sha": { "type": "string", @@ -2420,6 +2476,18 @@ "type": "boolean", "description": "The flag to indicate if the application is static." }, + "is_spa": { + "type": "boolean", + "description": "The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true." + }, + "is_auto_deploy_enabled": { + "type": "boolean", + "description": "The flag to indicate if auto-deploy is enabled on git push. Defaults to true." + }, + "is_force_https_enabled": { + "type": "boolean", + "description": "The flag to indicate if HTTPS is forced. Defaults to true." + }, "install_command": { "type": "string", "description": "The install command." @@ -2582,6 +2650,10 @@ "type": "string", "description": "The Dockerfile content." }, + "dockerfile_location": { + "type": "string", + "description": "The Dockerfile location in the repository." + }, "docker_compose_location": { "type": "string", "description": "The Docker Compose location." diff --git a/openapi.yaml b/openapi.yaml index 75ccb69fe..5d7adec32 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -97,7 +97,7 @@ paths: description: 'The application description.' domains: type: string - description: 'The application domains.' + description: 'The application URLs in a comma-separated list.' git_commit_sha: type: string description: 'The git commit SHA.' @@ -110,6 +110,15 @@ paths: is_static: type: boolean description: 'The flag to indicate if the application is static.' + is_spa: + type: boolean + description: 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.' + is_auto_deploy_enabled: + type: boolean + description: 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.' + is_force_https_enabled: + type: boolean + description: 'The flag to indicate if HTTPS is forced. Defaults to true.' static_image: type: string enum: ['nginx:alpine'] @@ -234,6 +243,9 @@ paths: dockerfile: type: string description: 'The Dockerfile content.' + dockerfile_location: + type: string + description: 'The Dockerfile location in the repository.' docker_compose_location: type: string description: 'The Docker Compose location.' @@ -369,7 +381,7 @@ paths: description: 'The application description.' domains: type: string - description: 'The application domains.' + description: 'The application URLs in a comma-separated list.' git_commit_sha: type: string description: 'The git commit SHA.' @@ -382,6 +394,15 @@ paths: is_static: type: boolean description: 'The flag to indicate if the application is static.' + is_spa: + type: boolean + description: 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.' + is_auto_deploy_enabled: + type: boolean + description: 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.' + is_force_https_enabled: + type: boolean + description: 'The flag to indicate if HTTPS is forced. Defaults to true.' static_image: type: string enum: ['nginx:alpine'] @@ -506,6 +527,9 @@ paths: dockerfile: type: string description: 'The Dockerfile content.' + dockerfile_location: + type: string + description: 'The Dockerfile location in the repository' docker_compose_location: type: string description: 'The Docker Compose location.' @@ -641,7 +665,7 @@ paths: description: 'The application description.' domains: type: string - description: 'The application domains.' + description: 'The application URLs in a comma-separated list.' git_commit_sha: type: string description: 'The git commit SHA.' @@ -654,6 +678,15 @@ paths: is_static: type: boolean description: 'The flag to indicate if the application is static.' + is_spa: + type: boolean + description: 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.' + is_auto_deploy_enabled: + type: boolean + description: 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.' + is_force_https_enabled: + type: boolean + description: 'The flag to indicate if HTTPS is forced. Defaults to true.' static_image: type: string enum: ['nginx:alpine'] @@ -778,6 +811,9 @@ paths: dockerfile: type: string description: 'The Dockerfile content.' + dockerfile_location: + type: string + description: 'The Dockerfile location in the repository.' docker_compose_location: type: string description: 'The Docker Compose location.' @@ -903,7 +939,7 @@ paths: description: 'The application description.' domains: type: string - description: 'The application domains.' + description: 'The application URLs in a comma-separated list.' docker_registry_image_name: type: string description: 'The docker registry image name.' @@ -1015,6 +1051,9 @@ paths: instant_deploy: type: boolean description: 'The flag to indicate if the application should be deployed instantly.' + is_force_https_enabled: + type: boolean + description: 'The flag to indicate if HTTPS is forced. Defaults to true.' use_build_server: type: boolean nullable: true @@ -1124,7 +1163,7 @@ paths: description: 'The application description.' domains: type: string - description: 'The application domains.' + description: 'The application URLs in a comma-separated list.' ports_mappings: type: string description: 'The ports mappings.' @@ -1227,6 +1266,9 @@ paths: instant_deploy: type: boolean description: 'The flag to indicate if the application should be deployed instantly.' + is_force_https_enabled: + type: boolean + description: 'The flag to indicate if HTTPS is forced. Defaults to true.' use_build_server: type: boolean nullable: true @@ -1524,7 +1566,7 @@ paths: description: 'The application description.' domains: type: string - description: 'The application domains.' + description: 'The application URLs in a comma-separated list.' git_commit_sha: type: string description: 'The git commit SHA.' @@ -1537,6 +1579,15 @@ paths: is_static: type: boolean description: 'The flag to indicate if the application is static.' + is_spa: + type: boolean + description: 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.' + is_auto_deploy_enabled: + type: boolean + description: 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.' + is_force_https_enabled: + type: boolean + description: 'The flag to indicate if HTTPS is forced. Defaults to true.' install_command: type: string description: 'The install command.' @@ -1657,6 +1708,9 @@ paths: dockerfile: type: string description: 'The Dockerfile content.' + dockerfile_location: + type: string + description: 'The Dockerfile location in the repository.' docker_compose_location: type: string description: 'The Docker Compose location.' From 650186b1abca9d600cb307612fc690851eb83723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=8F=94=EF=B8=8F=20Peak?= <122374094+peaklabs-dev@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:03:10 +0100 Subject: [PATCH 011/110] fix(preview): docker compose preview URLs (#7959) --- app/Jobs/ApplicationPullRequestUpdateJob.php | 25 +++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/app/Jobs/ApplicationPullRequestUpdateJob.php b/app/Jobs/ApplicationPullRequestUpdateJob.php index 05453b6a3..025daa12b 100755 --- a/app/Jobs/ApplicationPullRequestUpdateJob.php +++ b/app/Jobs/ApplicationPullRequestUpdateJob.php @@ -47,7 +47,7 @@ public function handle() match ($this->status) { ProcessStatus::QUEUED => $this->body = "The preview deployment for **{$serviceName}** is queued. ⏳\n\n", ProcessStatus::IN_PROGRESS => $this->body = "The preview deployment for **{$serviceName}** is in progress. 🟡\n\n", - ProcessStatus::FINISHED => $this->body = "The preview deployment for **{$serviceName}** is ready. 🟢\n\n".($this->preview->fqdn ? "[Open Preview]({$this->preview->fqdn}) | " : ''), + ProcessStatus::FINISHED => $this->body = "The preview deployment for **{$serviceName}** is ready. 🟢\n\n".$this->getPreviewLinks(), ProcessStatus::ERROR => $this->body = "The preview deployment for **{$serviceName}** failed. 🔴\n\n", ProcessStatus::KILLED => $this->body = "The preview deployment for **{$serviceName}** was killed. ⚫\n\n", ProcessStatus::CANCELLED => $this->body = "The preview deployment for **{$serviceName}** was cancelled. 🚫\n\n", @@ -91,4 +91,27 @@ private function delete_comment() { githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/issues/comments/{$this->preview->pull_request_issue_comment_id}", method: 'delete'); } + + private function getPreviewLinks(): string + { + if ($this->application->build_pack === 'dockercompose') { + $dockerComposeDomains = json_decode($this->preview->docker_compose_domains, true) ?? []; + $links = []; + + foreach ($dockerComposeDomains as $serviceName => $config) { + $domain = data_get($config, 'domain'); + if (! empty($domain)) { + $firstDomain = str($domain)->explode(',')->first(); + $firstDomain = trim($firstDomain); + if (! empty($firstDomain)) { + $links[] = "[Open {$serviceName}]({$firstDomain})"; + } + } + } + + return ! empty($links) ? implode(' | ', $links).' | ' : ''; + } + + return $this->preview->fqdn ? "[Open Preview]({$this->preview->fqdn}) | " : ''; + } } From 51301fd12ee04351ecf2187d2e7d73df4ce8f0b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=8F=94=EF=B8=8F=20Peak?= <122374094+peaklabs-dev@users.noreply.github.com> Date: Thu, 15 Jan 2026 21:59:51 +0100 Subject: [PATCH 012/110] feat(notifications): add mattermost notifications (#7963) --- app/Jobs/SendMessageToSlackJob.php | 50 ++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/app/Jobs/SendMessageToSlackJob.php b/app/Jobs/SendMessageToSlackJob.php index dd5335850..fcd87a9dd 100644 --- a/app/Jobs/SendMessageToSlackJob.php +++ b/app/Jobs/SendMessageToSlackJob.php @@ -22,6 +22,36 @@ public function __construct( } public function handle(): void + { + if ($this->isSlackWebhook()) { + $this->sendToSlack(); + + return; + } + + /** + * This works with Mattermost and as a fallback also with Slack, the notifications just look slightly different and advanced formatting for slack is not supported with Mattermost. + * + * @see https://github.com/coollabsio/coolify/pull/6139#issuecomment-3756777708 + */ + $this->sendToMattermost(); + } + + private function isSlackWebhook(): bool + { + $parsedUrl = parse_url($this->webhookUrl); + + if ($parsedUrl === false) { + return false; + } + + $scheme = $parsedUrl['scheme'] ?? ''; + $host = $parsedUrl['host'] ?? ''; + + return $scheme === 'https' && $host === 'hooks.slack.com'; + } + + private function sendToSlack(): void { Http::post($this->webhookUrl, [ 'text' => $this->message->title, @@ -57,4 +87,24 @@ public function handle(): void ], ]); } + + /** + * @todo v5 refactor: Extract this into a separate SendMessageToMattermostJob.php triggered via the "mattermost" notification channel type. + */ + private function sendToMattermost(): void + { + $username = config('app.name'); + + Http::post($this->webhookUrl, [ + 'username' => $username, + 'attachments' => [ + [ + 'title' => $this->message->title, + 'color' => $this->message->color, + 'text' => $this->message->description, + 'footer' => $username, + ], + ], + ]); + } } From 95091e918ffd1623abb9f177ae6f73e36b30a3f0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 16 Jan 2026 11:51:26 +0100 Subject: [PATCH 013/110] fix: optimize queries and caching for projects and environments --- app/Livewire/Project/Resource/Index.php | 55 +++++++++++++++---- app/Models/InstanceSettings.php | 6 +- app/Models/Server.php | 40 ++++++++++++++ app/Models/StandaloneDocker.php | 22 ++++++++ app/Models/SwarmDocker.php | 22 ++++++++ .../resources/breadcrumbs.blade.php | 44 ++++++++++++--- .../views/livewire/project/index.blade.php | 2 +- .../livewire/project/resource/index.blade.php | 28 +++++----- tests/Pest.php | 19 +++++++ 9 files changed, 203 insertions(+), 35 deletions(-) diff --git a/app/Livewire/Project/Resource/Index.php b/app/Livewire/Project/Resource/Index.php index 2b199dcfd..be6e3e98f 100644 --- a/app/Livewire/Project/Resource/Index.php +++ b/app/Livewire/Project/Resource/Index.php @@ -33,6 +33,10 @@ class Index extends Component public Collection $services; + public Collection $allProjects; + + public Collection $allEnvironments; + public array $parameters; public function mount() @@ -50,6 +54,33 @@ public function mount() ->firstOrFail(); $this->project = $project; + + // Load projects and environments for breadcrumb navigation (avoids inline queries in view) + $this->allProjects = Project::ownedByCurrentTeamCached(); + $this->allEnvironments = $project->environments() + ->with([ + 'applications.additional_servers', + 'applications.destination.server', + 'services', + 'services.destination.server', + 'postgresqls', + 'postgresqls.destination.server', + 'redis', + 'redis.destination.server', + 'mongodbs', + 'mongodbs.destination.server', + 'mysqls', + 'mysqls.destination.server', + 'mariadbs', + 'mariadbs.destination.server', + 'keydbs', + 'keydbs.destination.server', + 'dragonflies', + 'dragonflies.destination.server', + 'clickhouses', + 'clickhouses.destination.server', + ])->get(); + $this->environment = $environment->loadCount([ 'applications', 'redis', @@ -71,11 +102,13 @@ public function mount() 'destination.server.settings', 'settings', ])->get()->sortBy('name'); - $this->applications = $this->applications->map(function ($application) { + $projectUuid = $this->project->uuid; + $environmentUuid = $this->environment->uuid; + $this->applications = $this->applications->map(function ($application) use ($projectUuid, $environmentUuid) { $application->hrefLink = route('project.application.configuration', [ - 'project_uuid' => data_get($application, 'environment.project.uuid'), - 'environment_uuid' => data_get($application, 'environment.uuid'), - 'application_uuid' => data_get($application, 'uuid'), + 'project_uuid' => $projectUuid, + 'environment_uuid' => $environmentUuid, + 'application_uuid' => $application->uuid, ]); return $application; @@ -98,11 +131,11 @@ public function mount() 'tags', 'destination.server.settings', ])->get()->sortBy('name'); - $this->{$property} = $this->{$property}->map(function ($db) { + $this->{$property} = $this->{$property}->map(function ($db) use ($projectUuid, $environmentUuid) { $db->hrefLink = route('project.database.configuration', [ - 'project_uuid' => $this->project->uuid, + 'project_uuid' => $projectUuid, 'database_uuid' => $db->uuid, - 'environment_uuid' => data_get($this->environment, 'uuid'), + 'environment_uuid' => $environmentUuid, ]); return $db; @@ -114,11 +147,11 @@ public function mount() 'tags', 'destination.server.settings', ])->get()->sortBy('name'); - $this->services = $this->services->map(function ($service) { + $this->services = $this->services->map(function ($service) use ($projectUuid, $environmentUuid) { $service->hrefLink = route('project.service.configuration', [ - 'project_uuid' => data_get($service, 'environment.project.uuid'), - 'environment_uuid' => data_get($service, 'environment.uuid'), - 'service_uuid' => data_get($service, 'uuid'), + 'project_uuid' => $projectUuid, + 'environment_uuid' => $environmentUuid, + 'service_uuid' => $service->uuid, ]); return $service; diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 376242ca0..ccc361d67 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Once; use Spatie\Url\Url; class InstanceSettings extends Model @@ -35,6 +36,9 @@ class InstanceSettings extends Model protected static function booted(): void { static::updated(function ($settings) { + // Clear once() cache so subsequent calls get fresh data + Once::flush(); + // Clear trusted hosts cache when FQDN changes if ($settings->wasChanged('fqdn')) { \Cache::forget('instance_settings_fqdn_host'); @@ -82,7 +86,7 @@ public function autoUpdateFrequency(): Attribute public static function get() { - return InstanceSettings::findOrFail(0); + return once(fn () => InstanceSettings::findOrFail(0)); } // public function getRecipients($notification) diff --git a/app/Models/Server.php b/app/Models/Server.php index 2319e0303..d693aea6d 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -108,6 +108,12 @@ class Server extends BaseModel public static $batch_counter = 0; + /** + * Identity map cache for request-scoped Server lookups. + * Prevents N+1 queries when the same Server is accessed multiple times. + */ + private static ?array $identityMapCache = null; + protected $appends = ['is_coolify_host']; protected static function booted() @@ -186,6 +192,40 @@ protected static function booted() $server->settings()->delete(); $server->sslCertificates()->delete(); }); + + static::updated(function () { + static::flushIdentityMap(); + }); + } + + /** + * Find a Server by ID using the identity map cache. + * This prevents N+1 queries when the same Server is accessed multiple times. + */ + public static function findCached($id): ?static + { + if ($id === null) { + return null; + } + + if (static::$identityMapCache === null) { + static::$identityMapCache = []; + } + + if (! isset(static::$identityMapCache[$id])) { + static::$identityMapCache[$id] = static::query()->find($id); + } + + return static::$identityMapCache[$id]; + } + + /** + * Flush the identity map cache. + * Called automatically on update, and should be called in tests. + */ + public static function flushIdentityMap(): void + { + static::$identityMapCache = null; } protected $casts = [ diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index 9f5f0b33e..62ef68434 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -73,6 +73,28 @@ public function server() return $this->belongsTo(Server::class); } + /** + * Get the server attribute using identity map caching. + * This intercepts lazy-loading to use cached Server lookups. + */ + public function getServerAttribute(): ?Server + { + // Use eager loaded data if available + if ($this->relationLoaded('server')) { + return $this->getRelation('server'); + } + + // Use identity map for lazy loading + $server = Server::findCached($this->server_id); + + // Cache in relation for future access on this instance + if ($server) { + $this->setRelation('server', $server); + } + + return $server; + } + public function services() { return $this->morphMany(Service::class, 'destination'); diff --git a/app/Models/SwarmDocker.php b/app/Models/SwarmDocker.php index e0fe349c7..08be81970 100644 --- a/app/Models/SwarmDocker.php +++ b/app/Models/SwarmDocker.php @@ -56,6 +56,28 @@ public function server() return $this->belongsTo(Server::class); } + /** + * Get the server attribute using identity map caching. + * This intercepts lazy-loading to use cached Server lookups. + */ + public function getServerAttribute(): ?Server + { + // Use eager loaded data if available + if ($this->relationLoaded('server')) { + return $this->getRelation('server'); + } + + // Use identity map for lazy loading + $server = Server::findCached($this->server_id); + + // Cache in relation for future access on this instance + if ($server) { + $this->setRelation('server', $server); + } + + return $server; + } + public function services() { return $this->morphMany(Service::class, 'destination'); diff --git a/resources/views/components/resources/breadcrumbs.blade.php b/resources/views/components/resources/breadcrumbs.blade.php index 380c3270a..135cad3a7 100644 --- a/resources/views/components/resources/breadcrumbs.blade.php +++ b/resources/views/components/resources/breadcrumbs.blade.php @@ -2,12 +2,28 @@ 'lastDeploymentInfo' => null, 'lastDeploymentLink' => null, 'resource' => null, + 'projects' => null, + 'environments' => null, ]) @php - $projects = auth()->user()->currentTeam()->projects()->get(); - $environments = $resource->environment->project + use App\Models\Project; + + // Use passed props if available, otherwise query (backwards compatible) + $projects = $projects ?? Project::ownedByCurrentTeamCached(); + $environments = $environments ?? $resource->environment->project ->environments() - ->with(['applications', 'services']) + ->with([ + 'applications', + 'services', + 'postgresqls', + 'redis', + 'mongodbs', + 'mysqls', + 'mariadbs', + 'keydbs', + 'dragonflies', + 'clickhouses', + ]) ->get(); $currentProjectUuid = data_get($resource, 'environment.project.uuid'); $currentEnvironmentUuid = data_get($resource, 'environment.uuid'); @@ -74,6 +90,16 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar"> @foreach ($environments as $environment) @php + // Use pre-loaded relations instead of databases() method to avoid N+1 queries + $envDatabases = collect() + ->merge($environment->postgresqls ?? collect()) + ->merge($environment->redis ?? collect()) + ->merge($environment->mongodbs ?? collect()) + ->merge($environment->mysqls ?? collect()) + ->merge($environment->mariadbs ?? collect()) + ->merge($environment->keydbs ?? collect()) + ->merge($environment->dragonflies ?? collect()) + ->merge($environment->clickhouses ?? collect()); $envResources = collect() ->merge( $environment->applications->map( @@ -81,9 +107,7 @@ class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 bor ), ) ->merge( - $environment - ->databases() - ->map(fn($db) => ['type' => 'database', 'resource' => $db]), + $envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]), ) ->merge( $environment->services->map( @@ -173,7 +197,9 @@ class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 bor ]), }; $isCurrentResource = $res->uuid === $currentResourceUuid; - $resHasMultipleServers = $resType === 'application' && method_exists($res, 'additional_servers') && $res->additional_servers()->count() > 0; + // Use loaded relation count if available, otherwise check additional_servers_count attribute + $resHasMultipleServers = $resType === 'application' && method_exists($res, 'additional_servers') && + ($res->relationLoaded('additional_servers') ? $res->additional_servers->count() > 0 : ($res->additional_servers_count ?? 0) > 0); $resServerName = $resHasMultipleServers ? null : data_get($res, 'destination.server.name'); @endphp
y)HIwQd
z>hWq*AHNiKe|ZUhG(~9$50l9Ymn+mXZ5S2odacsNmV
zhj}7$XZ2CikaVfAsIo|~$k}(AfIuCr-@4#m$yS)8m_(WMVTNKBWv*YeskW&0ujU1N
zgS){dr_QIir^n!PH^&^Y9HyKb0ieL08^U*;pR1eP4B(Iue;PJ@z_8xEZ}cJzSrW
zy#f9yyumvEd(v=_9QKX1Eo=9{(lrtY)FgtiD~^rO&yP-}6ZKdZ-%qFi11
z*k1Kn%BKZpk@!%h?jNAW
W0(S9JYK&VGL?A@+PB9uWD`ccMW*
zi;8#iqkrDl8f-z68rg$Ji4Qd>2Wy|@-i9HLqYeS-!{y%0fF3KE?kzIpNw|HqFFmML
z#^ZT*bnevR9!*`2S~uOBNGTq&8ZV;Q=?h_ZZACA7$pRNUXUFT@l;ZSDi)q(7xH1I3
zb_Dm0sLe-nAo-onJr(gXBjfRdOPc*=YHWS3A}QdEDL}U+jic@{43_ts$f*1%@IX;I
z^@fAlWX00iTNsvRya(BaJ-F2NJS)00JFm}GI1&^Hiyn${R@>dbqu&GQH(Hxnov4(k
zD#NkEcRe}XU8aws+8=&L;pXzVxi<4Uw^H(;UL6D=4SMO5$8}4)7#ev=iB&Q>K84ek
z=0~mU;E#?f=$D1=DqTqG;+%it^-v4uJIK)#6B&|C_E}yL(kA^w*fs9z8xDCh1buQ-
z9@5&oF63EqUl>_byW}}ZQLXZU4Wz2qGdEu%EKq`F-sVLa7{$dEa3|c&d^TUK@ZL+3
z(ph@3KZokCQ;VF5_Uq(%Dd;FMaIICowwttr@X?lU;uG-4H|bf7>{H4qI;uDaz8Ogx
zr-Wq{+g4Cyd
2dyMtg+IU
zR3tWl)g?4;=2LSyhgNFDCogzj$06I#JSr=H49_^Er82Cq!4618_B6f!$b*^!5^{Ft
zZ6?A6qG)n!m!oZhNqMk#m76#I21Fs0*7o0v>jKa+GZ(+ha~J=Zsmc>Hu2OHF;MBx*
zZl8IR=It+ntnZA;M9t<)QZU0o`k*I?sA8I{s0nCXe2w_ri^Nxcj`D+R*qcAatjyyu
z{V}jk3#?LukKU#H<8v<5TB*S9=m8l`8Vd
zpC_4`L+UFBUq~27n8}mgb@sTsvw_+GsjtGyJbLmXE~;aepL8o{RZLj+F)uOQ!%t$|
z>{)DW%9jmvEt3Gdc%QVfh8vsmU9NlC*3-n7d7Qa|i&{gOgVZ@}5Mruhr0Z+_u4@}$
z>dUzKySVarSMRhzb9=Y0YEkFwFUz(38Z;aDjNSDmWNdn5rklL1z)^>)E=8+jNM#)B
z(tK>sl7!qADVc+kL=<2CU|Z{+{bn0+UpL%
BQ{Mb!$y_v&10?Bn#QA0WQ*5O^y)
zk8QLC%+v)AANU=L<7b%4-9X{M`ACvyC7xm-+>_SNZT8`BM