From dcd976ae065e1a2a003f62a12b29e49e41ac157e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:30:58 +0100 Subject: [PATCH 01/90] fix(git): ensure ssh credentials are propagated to submodule operations - Add gitSshCommand parameter to setGitImportSettings and buildGitCheckoutCommand - Extract hardcoded ssh commands to variables for consistency - Apply custom ssh credentials to all git operations including submodule sync/update - Configure git url rewriting for github token-based authentication with submodules - Add comprehensive test suite for submodule credential propagation --- app/Models/Application.php | 41 +++++--- openapi.json | 27 +++++ openapi.yaml | 21 ++++ tests/Unit/GitSubmoduleCredentialTest.php | 122 ++++++++++++++++++++++ 4 files changed, 196 insertions(+), 15 deletions(-) create mode 100644 tests/Unit/GitSubmoduleCredentialTest.php diff --git a/app/Models/Application.php b/app/Models/Application.php index 34ab4141e..e622c9d54 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1087,12 +1087,14 @@ public function dirOnServer() return application_configuration_dir()."/{$this->uuid}"; } - public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null) + public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $gitSshCommand = null) { $baseDir = $this->generateBaseDir($deployment_uuid); $escapedBaseDir = escapeshellarg($baseDir); $isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false; + $sshCommand = $gitSshCommand ?? 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'; + // Use the explicitly passed commit (e.g. from rollback), falling back to the application's git_commit_sha. // Invalid refs will cause the git checkout/fetch command to fail on the remote server. $commitToUse = $commit ?? $this->git_commit_sha; @@ -1102,9 +1104,9 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_ // If shallow clone is enabled and we need a specific commit, // we need to fetch that specific commit with depth=1 if ($isShallowCloneEnabled) { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$sshCommand}\" git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } else { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$sshCommand}\" git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } } if ($this->settings->is_git_submodules_enabled) { @@ -1115,10 +1117,10 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_ } // Add shallow submodules flag if shallow clone is enabled $submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : ''; - $git_clone_command = "{$git_clone_command} git submodule sync && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive {$submoduleFlags}; fi"; + $git_clone_command = "{$git_clone_command} git submodule sync && GIT_SSH_COMMAND=\"{$sshCommand}\" git submodule update --init --recursive {$submoduleFlags}; fi"; } if ($this->settings->is_git_lfs_enabled) { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$sshCommand}\" git lfs pull"; } return $git_clone_command; @@ -1301,12 +1303,18 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } } else { $github_access_token = generateGithubInstallationToken($this->source); + // Configure git to rewrite URLs with the token so submodules on the same host authenticate correctly + $escapedTokenUrl = escapeshellarg("{$source_html_url_scheme}://x-access-token:{$github_access_token}@{$source_html_url_host}/"); + $escapedHostUrl = escapeshellarg("{$source_html_url_scheme}://{$source_html_url_host}/"); + $gitConfigCommand = "git config --global url.{$escapedTokenUrl}.insteadOf {$escapedHostUrl}"; if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $gitConfigCommand)); $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; $escapedRepoUrl = escapeshellarg($repoUrl); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; $fullRepoUrl = $repoUrl; } else { + $commands->push($gitConfigCommand); $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; $escapedRepoUrl = escapeshellarg($repoUrl); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; @@ -1348,11 +1356,12 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } $private_key = base64_encode($private_key); $escapedCustomRepository = escapeshellarg($customRepository); - $git_clone_command_base = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + $deployKeySshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"; + $git_clone_command_base = "GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand); } if ($exec_in_docker) { $commands = collect([ @@ -1375,7 +1384,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1383,14 +1392,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" ".$this->buildGitCheckoutCommand($commit, $deployKeySshCommand); } } @@ -1411,6 +1420,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $escapedCustomRepository = escapeshellarg($customRepository); $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit); + $otherSshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"; if ($pull_request_id !== 0) { if ($git_type === 'gitlab') { @@ -1420,7 +1430,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1428,14 +1438,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand); } } @@ -1684,13 +1694,14 @@ public function fqdns(): Attribute ); } - protected function buildGitCheckoutCommand($target): string + protected function buildGitCheckoutCommand($target, ?string $gitSshCommand = null): string { $escapedTarget = escapeshellarg($target); $command = "git checkout {$escapedTarget}"; if ($this->settings->is_git_submodules_enabled) { - $command .= ' && git submodule update --init --recursive'; + $sshCommand = $gitSshCommand ?? 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'; + $command .= " && GIT_SSH_COMMAND=\"{$sshCommand}\" git submodule update --init --recursive"; } return $command; diff --git a/openapi.json b/openapi.json index 69f5ef53d..849dee363 100644 --- a/openapi.json +++ b/openapi.json @@ -3339,6 +3339,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -5864,6 +5873,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -10561,6 +10579,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { diff --git a/openapi.yaml b/openapi.yaml index fab3df54e..226295cdb 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2111,6 +2111,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop application.' @@ -3806,6 +3813,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop database.' @@ -6645,6 +6659,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop service.' diff --git a/tests/Unit/GitSubmoduleCredentialTest.php b/tests/Unit/GitSubmoduleCredentialTest.php new file mode 100644 index 000000000..1adaf735f --- /dev/null +++ b/tests/Unit/GitSubmoduleCredentialTest.php @@ -0,0 +1,122 @@ +application = new Application; + $this->application->forceFill([ + 'uuid' => 'test-app-uuid', + 'git_commit_sha' => 'HEAD', + ]); + + $settings = new ApplicationSetting; + $settings->is_git_shallow_clone_enabled = false; + $settings->is_git_submodules_enabled = true; + $settings->is_git_lfs_enabled = false; + $this->application->setRelation('settings', $settings); + }); + + test('setGitImportSettings uses provided gitSshCommand for submodule update', function () { + $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: false, + gitSshCommand: $sshCommand + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive') + ->toContain('git submodule sync'); + }); + + test('setGitImportSettings uses default ssh command when no gitSshCommand provided', function () { + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: false, + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive'); + }); + + test('setGitImportSettings uses provided gitSshCommand for fetch and checkout', function () { + $this->application->git_commit_sha = 'abc123def456'; + $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: false, + gitSshCommand: $sshCommand + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git -c advice.detachedHead=false checkout'); + }); + + test('setGitImportSettings uses provided gitSshCommand for shallow fetch', function () { + $this->application->git_commit_sha = 'abc123def456'; + $this->application->settings->is_git_shallow_clone_enabled = true; + $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: false, + gitSshCommand: $sshCommand + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch --depth=1 origin'); + }); + + test('setGitImportSettings uses provided gitSshCommand for lfs pull', function () { + $this->application->settings->is_git_lfs_enabled = true; + $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: false, + gitSshCommand: $sshCommand + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git lfs pull'); + }); + + test('buildGitCheckoutCommand includes GIT_SSH_COMMAND for submodule update when provided', function () { + $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa'; + + $method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand'); + $result = $method->invoke($this->application, 'main', $sshCommand); + + expect($result) + ->toContain("git checkout 'main'") + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive'); + }); + + test('buildGitCheckoutCommand uses default ssh command for submodule update when none provided', function () { + $method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand'); + $result = $method->invoke($this->application, 'main'); + + expect($result) + ->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive'); + }); + + test('buildGitCheckoutCommand omits submodule update when submodules disabled', function () { + $this->application->settings->is_git_submodules_enabled = false; + + $method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand'); + $result = $method->invoke($this->application, 'main'); + + expect($result) + ->toContain("git checkout 'main'") + ->not->toContain('submodule'); + }); +}); From 9c8e5645b42703e2b56c31dd6c9a29c8d3a90d6b Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:20:41 +0530 Subject: [PATCH 02/90] feat(application): make ports_exposes optional for portless apps --- .../Api/ApplicationsController.php | 16 ++++----- app/Jobs/ApplicationDeploymentJob.php | 33 +++++++++++-------- app/Livewire/Project/Application/General.php | 3 +- app/Models/Application.php | 2 +- ...exposes_nullable_in_applications_table.php | 22 +++++++++++++ .../project/application/general.blade.php | 9 ++++- 6 files changed, 60 insertions(+), 25 deletions(-) create mode 100644 database/migrations/2026_03_26_000000_make_ports_exposes_nullable_in_applications_table.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 66f6a1ef8..6b57d8f5f 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -144,7 +144,7 @@ public function applications(Request $request) mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'], + required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack'], properties: [ 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], @@ -309,7 +309,7 @@ public function create_public_application(Request $request) mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'], + required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack'], properties: [ 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], @@ -474,7 +474,7 @@ public function create_private_gh_app_application(Request $request) mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'], + required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack'], properties: [ 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], @@ -776,7 +776,7 @@ public function create_dockerfile_application(Request $request) mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name', 'ports_exposes'], + required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name'], properties: [ 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], @@ -1114,7 +1114,7 @@ private function create_application(Request $request, $type) 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], - 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', + 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', @@ -1307,7 +1307,7 @@ private function create_application(Request $request, $type) 'git_repository' => 'string|required', 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], - 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', + 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable', 'github_app_uuid' => 'string|required', 'watch_paths' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', @@ -1534,7 +1534,7 @@ private function create_application(Request $request, $type) 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], - 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', + 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable', 'private_key_uuid' => 'string|required', 'watch_paths' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', @@ -1835,7 +1835,7 @@ private function create_application(Request $request, $type) $validationRules = [ 'docker_registry_image_name' => 'string|required', 'docker_registry_image_tag' => 'string', - 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', + 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable', ]; $validationRules = array_merge(sharedDataApplications(), $validationRules); $validator = customApiValidator($request->all(), $validationRules); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 785e8c8e3..41eb81453 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1282,7 +1282,7 @@ private function generate_runtime_environment_variables() // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { - if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) { + if ($this->application->environment_variables->where('key', 'PORT')->isEmpty() && ! empty($ports)) { $envs->push("PORT={$ports[0]}"); } } @@ -2585,7 +2585,7 @@ private function generate_compose_file() 'image' => $this->production_image_name, 'container_name' => $this->container_name, 'restart' => RESTART_MODE, - 'expose' => $ports, + ...(!empty($ports) ? ['expose' => $ports] : []), 'networks' => [ $this->destination->network => [ 'aliases' => array_merge( @@ -2617,16 +2617,19 @@ private function generate_compose_file() // If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used // If healthcheck is disabled, no healthcheck will be added if (! $this->application->custom_healthcheck_found && ! $this->application->isHealthcheckDisabled()) { - $docker_compose['services'][$this->container_name]['healthcheck'] = [ - 'test' => [ - 'CMD-SHELL', - $this->generate_healthcheck_commands(), - ], - 'interval' => $this->application->health_check_interval.'s', - 'timeout' => $this->application->health_check_timeout.'s', - 'retries' => $this->application->health_check_retries, - 'start_period' => $this->application->health_check_start_period.'s', - ]; + $healthcheck_command = $this->generate_healthcheck_commands(); + if ($healthcheck_command !== null) { + $docker_compose['services'][$this->container_name]['healthcheck'] = [ + 'test' => [ + 'CMD-SHELL', + $healthcheck_command, + ], + 'interval' => $this->application->health_check_interval.'s', + 'timeout' => $this->application->health_check_timeout.'s', + 'retries' => $this->application->health_check_retries, + 'start_period' => $this->application->health_check_start_period.'s', + ]; + } } if (! is_null($this->application->limits_cpuset)) { @@ -2836,7 +2839,11 @@ private function generate_healthcheck_commands() // HTTP type healthcheck (default) if (! $this->application->health_check_port) { - $health_check_port = (int) $this->application->ports_exposes_array[0]; + if (! empty($this->application->ports_exposes_array)) { + $health_check_port = (int) $this->application->ports_exposes_array[0]; + } else { + return null; + } } else { $health_check_port = (int) $this->application->health_check_port; } diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 5c186af70..885c9dbac 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -153,7 +153,7 @@ protected function rules(): array 'staticImage' => 'required', 'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)), 'publishDirectory' => ValidationPatterns::directoryPathRules(), - 'portsExposes' => 'required', + 'portsExposes' => 'nullable', 'portsMappings' => 'nullable', 'customNetworkAliases' => 'nullable', 'dockerfile' => 'nullable', @@ -208,7 +208,6 @@ protected function messages(): array 'buildPack.required' => 'The Build Pack field is required.', 'staticImage.required' => 'The Static Image field is required.', 'baseDirectory.required' => 'The Base Directory field is required.', - 'portsExposes.required' => 'The Exposed Ports field is required.', 'isStatic.required' => 'The Static setting is required.', 'isStatic.boolean' => 'The Static setting must be true or false.', 'isSpa.required' => 'The SPA setting is required.', diff --git a/app/Models/Application.php b/app/Models/Application.php index 4cc2dcf74..75b81920d 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -2147,7 +2147,7 @@ public function setConfig($config) 'config.build_pack' => 'required|string', 'config.base_directory' => 'required|string', 'config.publish_directory' => 'required|string', - 'config.ports_exposes' => 'required|string', + 'config.ports_exposes' => 'nullable|string', 'config.settings.is_static' => 'required|boolean', ]); if ($deepValidator->fails()) { diff --git a/database/migrations/2026_03_26_000000_make_ports_exposes_nullable_in_applications_table.php b/database/migrations/2026_03_26_000000_make_ports_exposes_nullable_in_applications_table.php new file mode 100644 index 000000000..ac7b5cb55 --- /dev/null +++ b/database/migrations/2026_03_26_000000_make_ports_exposes_nullable_in_applications_table.php @@ -0,0 +1,22 @@ +string('ports_exposes')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->string('ports_exposes')->nullable(false)->default('')->change(); + }); + } +}; diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index d743e346e..9539f47dc 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -492,6 +492,13 @@ class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-blue-50 dark:bg-blu @endif @endif + @if (empty($portsExposes) || $portsExposes === '0') + + This application does not expose any ports and will not be reachable through the proxy or your domains. + This behavior is normal for background workers, bots, or scheduled tasks. + If your application needs to handle HTTP traffic, please specify the port(s) it listens on. + + @endif
@if ($isStatic || $buildPack === 'static') @else - @endif From 8c4865215b9ba90f0d37c7d2e81b351e8974001e Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:26:50 +0530 Subject: [PATCH 03/90] feat(ui): show info callout only when domain is set without exposed ports --- resources/views/livewire/project/application/general.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 9539f47dc..91e462b46 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -492,7 +492,7 @@ class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-blue-50 dark:bg-blu
@endif @endif - @if (empty($portsExposes) || $portsExposes === '0') + @if ((empty($portsExposes) || $portsExposes === '0') && !empty($fqdn)) This application does not expose any ports and will not be reachable through the proxy or your domains. This behavior is normal for background workers, bots, or scheduled tasks. From 886d01405f5da60c62e4fedd7d0c8d212caf1202 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:48:10 +0530 Subject: [PATCH 04/90] feat(applications): add configurable restart loop limit --- app/Actions/Docker/GetContainersStatus.php | 17 +-- app/Livewire/Project/Application/Advanced.php | 19 +++ app/Models/Application.php | 1 + .../Application/RestartLimitReached.php | 140 ++++++++++++++++++ ...rt_count_to_applications_and_databases.php | 22 +++ ...pplication-restart-limit-reached.blade.php | 7 + .../project/application/advanced.blade.php | 8 + 7 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 app/Notifications/Application/RestartLimitReached.php create mode 100644 database/migrations/2026_03_27_000000_add_max_restart_count_to_applications_and_databases.php create mode 100644 resources/views/emails/application-restart-limit-reached.blade.php diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index 5966876c6..cfac83583 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -2,6 +2,7 @@ namespace App\Actions\Docker; +use App\Actions\Application\StopApplication; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; use App\Actions\Shared\ComplexStatusCheck; @@ -9,6 +10,7 @@ use App\Models\ApplicationPreview; use App\Models\Server; use App\Models\ServiceDatabase; +use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached; use App\Services\ContainerStatusAggregator; use App\Traits\CalculatesExcludedStatus; use Illuminate\Support\Arr; @@ -475,16 +477,11 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti 'last_restart_type' => 'crash', ]); - // Send notification - $containerName = $application->name; - $projectUuid = data_get($application, 'environment.project.uuid'); - $environmentName = data_get($application, 'environment.name'); - $applicationUuid = data_get($application, 'uuid'); - - if ($projectUuid && $applicationUuid && $environmentName) { - $url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid; - } else { - $url = null; + // Check if restart limit has been reached + $maxAllowedRestarts = $application->max_restart_count ?? 0; + if ($maxAllowedRestarts > 0 && $maxRestartCount >= $maxAllowedRestarts && $previousRestartCount < $maxAllowedRestarts) { + StopApplication::dispatch($application); + $application->environment->project->team?->notify(new ApplicationRestartLimitReached($application)); } } diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index cf7ef3e0b..9954c3422 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -82,6 +82,9 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $isConnectToDockerNetworkEnabled = false; + #[Validate(['integer', 'min:0'])] + public int $maxRestartCount = 10; + public function mount() { try { @@ -144,6 +147,7 @@ public function syncData(bool $toModel = false) $this->disableBuildCache = $this->application->settings->disable_build_cache; $this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true; $this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false; + $this->maxRestartCount = $this->application->max_restart_count ?? 10; } } @@ -252,6 +256,21 @@ public function saveCustomName() } } + public function saveMaxRestartCount() + { + try { + $this->authorize('update', $this->application); + $this->validate([ + 'maxRestartCount' => 'integer|min:0', + ]); + $this->application->max_restart_count = $this->maxRestartCount; + $this->application->save(); + $this->dispatch('success', 'Max restart count saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { return view('livewire.project.application.advanced'); diff --git a/app/Models/Application.php b/app/Models/Application.php index 4cc2dcf74..85a04041b 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -125,6 +125,7 @@ class Application extends BaseModel protected $casts = [ 'http_basic_auth_password' => 'encrypted', 'restart_count' => 'integer', + 'max_restart_count' => 'integer', 'last_restart_at' => 'datetime', ]; diff --git a/app/Notifications/Application/RestartLimitReached.php b/app/Notifications/Application/RestartLimitReached.php new file mode 100644 index 000000000..8709e7cd3 --- /dev/null +++ b/app/Notifications/Application/RestartLimitReached.php @@ -0,0 +1,140 @@ +onQueue('high'); + $this->resource_name = data_get($resource, 'name'); + $this->project_uuid = data_get($resource, 'environment.project.uuid'); + $this->environment_uuid = data_get($resource, 'environment.uuid'); + $this->environment_name = data_get($resource, 'environment.name'); + $this->fqdn = data_get($resource, 'fqdn', null); + $this->restart_count = $resource->restart_count; + $this->max_restart_count = $resource->max_restart_count; + if (str($this->fqdn)->explode(',')->count() > 1) { + $this->fqdn = str($this->fqdn)->explode(',')->first(); + } + $this->resource_url = base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/application/{$this->resource->uuid}"; + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('status_change'); + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject("Coolify: {$this->resource_name} stopped - restart limit reached ({$this->restart_count}/{$this->max_restart_count})"); + $mail->view('emails.application-restart-limit-reached', [ + 'name' => $this->resource_name, + 'fqdn' => $this->fqdn, + 'resource_url' => $this->resource_url, + 'restart_count' => $this->restart_count, + 'max_restart_count' => $this->max_restart_count, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + return new DiscordMessage( + title: ':warning: Restart limit reached', + description: "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).\n\n[Open Application in Coolify]({$this->resource_url})", + color: DiscordMessage::errorColor(), + isCritical: true, + ); + } + + public function toTelegram(): array + { + $message = "Coolify: {$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count})."; + + return [ + 'message' => $message, + 'buttons' => [ + [ + 'text' => 'Open Application in Coolify', + 'url' => $this->resource_url, + ], + ], + ]; + } + + public function toPushover(): PushoverMessage + { + $message = "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count})."; + + return new PushoverMessage( + title: 'Restart limit reached', + level: 'error', + message: $message, + buttons: [ + [ + 'text' => 'Open Application in Coolify', + 'url' => $this->resource_url, + ], + ], + ); + } + + public function toSlack(): SlackMessage + { + $title = 'Restart limit reached'; + $description = "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count})"; + + $description .= "\n\n*Project:* ".data_get($this->resource, 'environment.project.name'); + $description .= "\n*Environment:* {$this->environment_name}"; + $description .= "\n*Application URL:* {$this->resource_url}"; + + return new SlackMessage( + title: $title, + description: $description, + color: SlackMessage::errorColor() + ); + } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => 'Restart limit reached', + 'event' => 'restart_limit_reached', + 'application_name' => $this->resource_name, + 'application_uuid' => $this->resource->uuid, + 'restart_count' => $this->restart_count, + 'max_restart_count' => $this->max_restart_count, + 'url' => $this->resource_url, + 'project' => data_get($this->resource, 'environment.project.name'), + 'environment' => $this->environment_name, + 'fqdn' => $this->fqdn, + ]; + } +} diff --git a/database/migrations/2026_03_27_000000_add_max_restart_count_to_applications_and_databases.php b/database/migrations/2026_03_27_000000_add_max_restart_count_to_applications_and_databases.php new file mode 100644 index 000000000..578959c9a --- /dev/null +++ b/database/migrations/2026_03_27_000000_add_max_restart_count_to_applications_and_databases.php @@ -0,0 +1,22 @@ +integer('max_restart_count')->default(10)->after('restart_count'); + }); + } + + public function down(): void + { + Schema::table('applications', function (Blueprint $blueprint) { + $blueprint->dropColumn('max_restart_count'); + }); + } +}; diff --git a/resources/views/emails/application-restart-limit-reached.blade.php b/resources/views/emails/application-restart-limit-reached.blade.php new file mode 100644 index 000000000..e5fa01c00 --- /dev/null +++ b/resources/views/emails/application-restart-limit-reached.blade.php @@ -0,0 +1,7 @@ + +{{ $name }} has been automatically stopped after {{ $restart_count }} crash restarts (limit: {{ $max_restart_count }}). + +The application appears to be in a crash loop. Please investigate the issue and redeploy when ready. + +[Check what is going on]({{ $resource_url }}). + diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index 907500dfa..92020334b 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -75,6 +75,14 @@ helper="By default, you do not reach the Coolify defined networks.
Starting a docker compose based resource will have an internal network.
If you connect to a Coolify defined network, you maybe need to use different internal DNS names to connect to a resource.

For more information, check this." canGate="update" :canResource="$application" /> @endif +

Restart Limit

+
+ + Save +

Logs

From 3d9c0b7b50c68efc03e277123dfacfc198237bd7 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:35:49 +0530 Subject: [PATCH 05/90] refactor(migration): align migration name with actual schema change --- ...> 2026_03_27_000000_add_max_restart_count_to_applications.php} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename database/migrations/{2026_03_27_000000_add_max_restart_count_to_applications_and_databases.php => 2026_03_27_000000_add_max_restart_count_to_applications.php} (100%) diff --git a/database/migrations/2026_03_27_000000_add_max_restart_count_to_applications_and_databases.php b/database/migrations/2026_03_27_000000_add_max_restart_count_to_applications.php similarity index 100% rename from database/migrations/2026_03_27_000000_add_max_restart_count_to_applications_and_databases.php rename to database/migrations/2026_03_27_000000_add_max_restart_count_to_applications.php From 6b1b1b14f295c73f4d90587809bb4b81ce6a3ddd Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:50:01 +0530 Subject: [PATCH 06/90] feat(ui): move Sentinel to dedicated tab with sidebar navigation and logs page --- app/Livewire/Server/Charts.php | 24 +++ app/Livewire/Server/Sentinel.php | 19 +- app/Livewire/Server/Sentinel/Logs.php | 31 ++++ app/Livewire/Server/Sentinel/Show.php | 28 +++ .../server/sidebar-sentinel.blade.php | 10 ++ .../views/components/server/sidebar.blade.php | 5 - .../views/livewire/server/charts.blade.php | 25 ++- .../views/livewire/server/navbar.blade.php | 26 ++- .../views/livewire/server/sentinel.blade.php | 170 +++++++----------- .../livewire/server/sentinel/logs.blade.php | 13 ++ .../livewire/server/sentinel/show.blade.php | 16 ++ routes/web.php | 6 +- 12 files changed, 242 insertions(+), 131 deletions(-) create mode 100644 app/Livewire/Server/Sentinel/Logs.php create mode 100644 app/Livewire/Server/Sentinel/Show.php create mode 100644 resources/views/components/server/sidebar-sentinel.blade.php create mode 100644 resources/views/livewire/server/sentinel/logs.blade.php create mode 100644 resources/views/livewire/server/sentinel/show.blade.php diff --git a/app/Livewire/Server/Charts.php b/app/Livewire/Server/Charts.php index d0db87f57..c09c11b60 100644 --- a/app/Livewire/Server/Charts.php +++ b/app/Livewire/Server/Charts.php @@ -2,11 +2,15 @@ namespace App\Livewire\Server; +use App\Actions\Server\StartSentinel; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Charts extends Component { + use AuthorizesRequests; + public Server $server; public $chartId = 'server'; @@ -28,6 +32,26 @@ public function mount(string $server_uuid) } } + public function toggleMetrics() + { + try { + $this->authorize('update', $this->server); + $this->server->settings->is_metrics_enabled = ! $this->server->settings->is_metrics_enabled; + $this->server->settings->save(); + $this->server->refresh(); + + if ($this->server->isMetricsEnabled()) { + StartSentinel::run($this->server, true); + $this->dispatch('success', 'Metrics enabled. Restarting Sentinel.'); + } else { + $this->server->restartSentinel(); + $this->dispatch('success', 'Metrics disabled. Restarting Sentinel.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function pollData() { if ($this->poll || $this->interval <= 10) { diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index a4b35891b..f90af5a16 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -15,8 +15,6 @@ class Sentinel extends Component public Server $server; - public array $parameters = []; - public bool $isMetricsEnabled; #[Validate(['required', 'string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'])] @@ -51,15 +49,9 @@ public function getListeners() ]; } - public function mount(string $server_uuid) + public function mount() { - try { - $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); - $this->parameters = get_route_parameters(); - $this->syncData(); - } catch (\Throwable) { - return redirect()->route('server.index'); - } + $this->syncData(); } public function syncData(bool $toModel = false) @@ -110,20 +102,21 @@ public function restartSentinel() } } - public function updatedIsSentinelEnabled($value) + public function toggleSentinel() { try { $this->authorize('manageSentinel', $this->server); - if ($value === true) { + if (! $this->isSentinelEnabled) { if ($this->server->isBuildServer()) { - $this->isSentinelEnabled = false; $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); return; } + $this->isSentinelEnabled = true; $customImage = isDev() ? $this->sentinelCustomDockerImage : null; StartSentinel::run($this->server, true, null, $customImage); } else { + $this->isSentinelEnabled = false; $this->isMetricsEnabled = false; $this->isSentinelDebugEnabled = false; StopSentinel::dispatch($this->server); diff --git a/app/Livewire/Server/Sentinel/Logs.php b/app/Livewire/Server/Sentinel/Logs.php new file mode 100644 index 000000000..513776a6f --- /dev/null +++ b/app/Livewire/Server/Sentinel/Logs.php @@ -0,0 +1,31 @@ +parameters = get_route_parameters(); + try { + $this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->first(); + if (is_null($this->server)) { + return redirect()->route('server.index'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.sentinel.logs'); + } +} diff --git a/app/Livewire/Server/Sentinel/Show.php b/app/Livewire/Server/Sentinel/Show.php new file mode 100644 index 000000000..4bfc4c398 --- /dev/null +++ b/app/Livewire/Server/Sentinel/Show.php @@ -0,0 +1,28 @@ +parameters = get_route_parameters(); + try { + $this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.sentinel.show'); + } +} diff --git a/resources/views/components/server/sidebar-sentinel.blade.php b/resources/views/components/server/sidebar-sentinel.blade.php new file mode 100644 index 000000000..8249c9413 --- /dev/null +++ b/resources/views/components/server/sidebar-sentinel.blade.php @@ -0,0 +1,10 @@ + diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 2d7649fab..82ec985e3 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -6,11 +6,6 @@ href="{{ route('server.advanced', ['server_uuid' => $server->uuid]) }}">Advanced @endif - @if ($server->isFunctional() && !$server->isSwarm() && !$server->isBuildServer()) - Sentinel - - @endif Private Key diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index 51953ab9a..0acb79b93 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -6,7 +6,18 @@
-

Metrics

+
+

Metrics

+ @if ($server->isMetricsEnabled()) + + Disable Metrics + + @elseif ($server->isSentinelEnabled()) + + Enable Metrics + + @endif +
Basic metrics for your server.
@if ($server->isMetricsEnabled())
@@ -288,8 +299,16 @@
@else -
Metrics are disabled for this server. Enable them in Sentinel settings.
+ @if ($server->isSentinelEnabled()) + + Metrics are disabled for this server. Click "Enable Metrics" above to start collecting metrics. + + @else + + Metrics require Sentinel to be enabled. + Please enable Sentinel first. + + @endif @endif
diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php index 4e53cd80e..bf55ca7f6 100644 --- a/resources/views/livewire/server/navbar.blade.php +++ b/resources/views/livewire/server/navbar.blade.php @@ -58,6 +58,17 @@ class="mx-1 dark:hover:fill-white fill-black dark:fill-warning"> @endif + @if ($server->isSentinelEnabled()) +
+
+ @if ($server->isSentinelLive()) + + @else + + @endif +
+
+ @endif
{{ data_get($server, 'name') }}
-
-
-
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
+
diff --git a/resources/views/livewire/project/database/redis/status-info.blade.php b/resources/views/livewire/project/database/redis/status-info.blade.php new file mode 100644 index 000000000..9f329504c --- /dev/null +++ b/resources/views/livewire/project/database/redis/status-info.blade.php @@ -0,0 +1,51 @@ +
+ + @if ($dbUrlPublic) + + @endif +
+
+
+

SSL Configuration

+ @if ($enableSsl && $certificateValidUntil) + + @endif +
+
+ @if ($enableSsl && $certificateValidUntil) + Valid until: + @if (now()->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired + @elseif(now()->addDays(30)->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring + soon + @else + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} + @endif + + @endif +
+
+ @if (str($database->status)->contains('exited')) + + @else + + @endif +
+
+
+
diff --git a/tests/Feature/DatabaseSslStatusRefreshTest.php b/tests/Feature/DatabaseSslStatusRefreshTest.php index e62ef48ad..b663213a5 100644 --- a/tests/Feature/DatabaseSslStatusRefreshTest.php +++ b/tests/Feature/DatabaseSslStatusRefreshTest.php @@ -7,11 +7,16 @@ use App\Livewire\Project\Database\Mysql\General as MysqlGeneral; use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral; use App\Livewire\Project\Database\Redis\General as RedisGeneral; +use App\Livewire\Project\Database\Redis\StatusInfo as RedisStatusInfo; +use App\Livewire\Server\Sentinel; +use App\Livewire\Server\Show; use App\Models\Environment; use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\StandaloneMysql; +use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -28,25 +33,75 @@ session(['currentTeam' => $this->team]); }); -dataset('ssl-aware-database-general-components', [ +dataset('database-general-forms-without-broadcasts', [ + // Redis splits status-derived display into a sibling component; the form itself + // takes no broadcast listeners. Other DBs use the narrower refreshStatus pattern below. + RedisGeneral::class, +]); + +dataset('database-general-forms-with-narrow-refresh', [ + // Form listens to status broadcasts but routes them to refreshStatus, which only + // writes display-only properties (URLs, cert expiry) — never input-bound text fields. + PostgresqlGeneral::class, MysqlGeneral::class, MariadbGeneral::class, MongodbGeneral::class, - RedisGeneral::class, - PostgresqlGeneral::class, KeydbGeneral::class, DragonflyGeneral::class, ]); -it('maps database status broadcasts to refresh for ssl-aware database general components', function (string $componentClass) { - $component = app($componentClass); - $listeners = $component->getListeners(); +dataset('database-status-info-components', [ + RedisStatusInfo::class, +]); - expect($listeners["echo-private:user.{$this->user->id},DatabaseStatusChanged"])->toBe('refresh') - ->and($listeners["echo-private:team.{$this->team->id},ServiceChecked"])->toBe('refresh'); -})->with('ssl-aware-database-general-components'); +it('does not subscribe the form to status broadcasts when display lives in a sibling', function (string $componentClass) { + // Regression guard for coolify#6062 / #6354 / #9695: + // For DBs whose status-derived display moved into a sibling component, the form + // itself must not subscribe to status broadcasts at all. + $listeners = resolveLivewireListeners(app($componentClass)); -it('reloads the mysql database model when refreshing so ssl controls follow the latest status', function () { + expect($listeners) + ->not->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged") + ->not->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked") + ->not->toHaveKey("echo-private:team.{$this->team->id},ServiceStatusChanged"); +})->with('database-general-forms-without-broadcasts'); + +it('routes status broadcasts to refreshStatus, never to a handler that re-syncs inputs', function (string $componentClass) { + // Regression guard for coolify#6062 / #6354 / #9695: + // The form may listen to broadcasts, but only to a narrow handler (refreshStatus) + // that touches display-only properties. Routing to `refresh` or `$refresh` would + // re-sync every input property from the DB and wipe in-progress typing. + $listeners = resolveLivewireListeners(app($componentClass)); + + $databaseStatusKey = "echo-private:user.{$this->user->id},DatabaseStatusChanged"; + $serviceCheckedKey = "echo-private:team.{$this->team->id},ServiceChecked"; + + expect($listeners[$databaseStatusKey] ?? null)->toBe('refreshStatus') + ->and($listeners[$serviceCheckedKey] ?? null)->toBe('refreshStatus'); +})->with('database-general-forms-with-narrow-refresh'); + +function resolveLivewireListeners(object $component): array +{ + // Livewire's HandlesEvents trait declares getListeners() as protected, + // so subclasses that override it as public are callable directly, but + // subclasses that rely on $listeners are not. Reflection handles both. + $method = new ReflectionMethod($component, 'getListeners'); + $method->setAccessible(true); + + return (array) $method->invoke($component); +} + +it('auto-refreshes status-info sibling on database status broadcasts', function (string $componentClass) { + // Status-derived display (connection URLs, SSL gate hint, cert expiry) lives in a sibling + // Livewire component so it can re-render on broadcasts without touching the form's DOM. + $listeners = resolveLivewireListeners(app($componentClass)); + + expect($listeners) + ->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged") + ->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked"); +})->with('database-status-info-components'); + +it('reloads the mysql database model when refresh is called directly so ssl controls follow the latest status', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $destination = StandaloneDocker::where('server_id', $server->id)->first(); $project = Project::factory()->create(['team_id' => $this->team->id]); @@ -75,3 +130,91 @@ $component->call('refresh') ->assertSee('Database should be stopped to change this settings.'); }); + +it('does not clobber server form text inputs when sentinel restarts', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'persisted-server-name', + ]); + + $component = Livewire::test(Sentinel::class, ['server_uuid' => $server->uuid]) + ->set('sentinelToken', 'user-was-typing-this-token'); + + $component->call('handleSentinelRestarted', ['serverUuid' => $server->uuid]); + + expect($component->get('sentinelToken'))->toBe('user-was-typing-this-token'); +}); + +it('does not clobber server form text inputs when server validation completes', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'persisted-server-name', + ]); + + $component = Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->set('name', 'user-was-typing-here') + ->set('ip', '203.0.113.42'); + + $component->call('handleServerValidated', ['serverUuid' => $server->uuid]); + + expect($component->get('name'))->toBe('user-was-typing-here') + ->and($component->get('ip'))->toBe('203.0.113.42'); +}); + +it('preserves typed input on the postgres form when refreshStatus runs', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $database = StandalonePostgresql::create([ + 'name' => 'persisted-name', + 'image' => 'postgres:16', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'status' => 'exited:unhealthy', + 'enable_ssl' => false, + 'is_log_drain_enabled' => false, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $component = Livewire::test(PostgresqlGeneral::class, ['database' => $database]) + ->set('name', 'user-was-typing-here') + ->set('portsMappings', '5433:5432'); + + $component->call('refreshStatus'); + + expect($component->get('name'))->toBe('user-was-typing-here') + ->and($component->get('portsMappings'))->toBe('5433:5432'); +}); + +it('shows the redis ssl gate hint after the sibling is refreshed', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $database = StandaloneRedis::create([ + 'name' => 'test-redis', + 'image' => 'redis:7', + 'redis_password' => 'password', + 'redis_username' => 'default', + 'status' => 'exited:unhealthy', + 'enable_ssl' => true, + 'is_log_drain_enabled' => false, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $component = Livewire::test(RedisStatusInfo::class, ['database' => $database]) + ->assertDontSee('Database should be stopped to change this settings.'); + + $database->fill(['status' => 'running:healthy'])->save(); + + $component->call('refresh') + ->assertSee('Database should be stopped to change this settings.'); +}); From e7e65831a7c0d6b2ac39e98f0ded48afb39317db Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Thu, 21 May 2026 08:31:08 +0000 Subject: [PATCH 17/90] fix(livewire): preserve wire:dirty across DB status broadcasts The earlier refreshStatus fix kept user-typed values intact but Livewire still absorbed deferred wire:model values into the snapshot on every broadcast- triggered roundtrip, clearing the unsaved-changes indicator and making the form look auto-saved. Move all status-derived display (DB URLs, SSL toggle/mode, cert expiry) out of each DB General form into a sibling StatusInfo Livewire component, so the form never roundtrips on broadcasts. Shared scaffolding lives in App\Traits\HasDatabaseStatusInfo plus an x-database- status-info Blade component, leaving each per-DB StatusInfo class as a ~20-50 line declaration of label, SSL mode options, and SSL save hooks. Parents dispatch databaseUpdated from save methods so the sibling refreshes after writes. Tests cover the architecture (no DB form subscribes to status broadcasts) and the sibling's refresh-on-status-change behavior. --- .../Project/Database/Clickhouse/General.php | 27 +-- .../Database/Clickhouse/StatusInfo.php | 31 ++++ .../Project/Database/Dragonfly/General.php | 104 +---------- .../Project/Database/Dragonfly/StatusInfo.php | 26 +++ .../Project/Database/Keydb/General.php | 106 +---------- .../Project/Database/Keydb/StatusInfo.php | 26 +++ .../Project/Database/Mariadb/General.php | 107 +----------- .../Project/Database/Mariadb/StatusInfo.php | 21 +++ .../Project/Database/Mongodb/General.php | 119 +------------ .../Project/Database/Mongodb/StatusInfo.php | 51 ++++++ .../Project/Database/Mysql/General.php | 119 +------------ .../Project/Database/Mysql/StatusInfo.php | 51 ++++++ .../Project/Database/Postgresql/General.php | 124 +------------ .../Database/Postgresql/StatusInfo.php | 52 ++++++ .../Project/Database/Redis/StatusInfo.php | 103 +---------- app/Traits/HasDatabaseStatusInfo.php | 164 ++++++++++++++++++ .../components/database-status-info.blade.php | 94 ++++++++++ .../database/clickhouse/general.blade.php | 13 +- .../database/dragonfly/general.blade.php | 54 +----- .../project/database/keydb/general.blade.php | 53 +----- .../database/mariadb/general.blade.php | 52 +----- .../database/mongodb/general.blade.php | 77 +------- .../project/database/mysql/general.blade.php | 74 +------- .../database/postgresql/general.blade.php | 126 +++----------- .../database/redis/status-info.blade.php | 51 ------ .../project/database/status-info.blade.php | 6 + .../Feature/DatabaseSslStatusRefreshTest.php | 80 +++------ 27 files changed, 603 insertions(+), 1308 deletions(-) create mode 100644 app/Livewire/Project/Database/Clickhouse/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Dragonfly/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Keydb/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Mariadb/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Mongodb/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Mysql/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Postgresql/StatusInfo.php create mode 100644 app/Traits/HasDatabaseStatusInfo.php create mode 100644 resources/views/components/database-status-info.blade.php delete mode 100644 resources/views/livewire/project/database/redis/status-info.blade.php create mode 100644 resources/views/livewire/project/database/status-info.blade.php diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index edcb31f5e..b5c0ffff4 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -40,34 +40,17 @@ class General extends Component public ?string $customDockerRunOptions = null; - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - public bool $isLogDrainEnabled = false; public function getListeners() { - $userId = Auth::id(); $teamId = Auth::user()->currentTeam()->id; return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } - public function refreshStatus(): void - { - $this->database->refresh(); - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; - } - public function mount() { try { @@ -101,8 +84,6 @@ protected function rules(): array 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', 'isLogDrainEnabled' => 'nullable|boolean', ]; } @@ -142,9 +123,6 @@ public function syncData(bool $toModel = false) $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -157,8 +135,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } } @@ -207,6 +183,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -218,6 +195,7 @@ public function instantSave() public function databaseProxyStopped() { $this->syncData(); + $this->dispatch('databaseUpdated'); } public function submit() @@ -233,6 +211,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { diff --git a/app/Livewire/Project/Database/Clickhouse/StatusInfo.php b/app/Livewire/Project/Database/Clickhouse/StatusInfo.php new file mode 100644 index 000000000..51a3192fa --- /dev/null +++ b/app/Livewire/Project/Database/Clickhouse/StatusInfo.php @@ -0,0 +1,31 @@ +currentTeam()->id; return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } - public function refreshStatus(): void - { - $this->database->refresh(); - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - public function mount() { try { @@ -84,12 +60,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -109,10 +79,7 @@ protected function rules(): array 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', 'isLogDrainEnabled' => 'nullable|boolean', - 'enable_ssl' => 'nullable|boolean', ]; } @@ -148,11 +115,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->database->enable_ssl = $this->enable_ssl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -164,9 +127,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->enable_ssl = $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } } @@ -215,6 +175,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -226,6 +187,7 @@ public function instantSave() public function databaseProxyStopped() { $this->syncData(); + $this->dispatch('databaseUpdated'); } public function submit() @@ -241,6 +203,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -252,67 +215,6 @@ public function submit() } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $server = $this->database->destination->server; - - $caCert = $server->sslCertificates() - ->where('is_ca_certificate', true) - ->first(); - - if (! $caCert) { - $server->generateCaCertificate(); - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Dragonfly/StatusInfo.php b/app/Livewire/Project/Database/Dragonfly/StatusInfo.php new file mode 100644 index 000000000..baeb3d09f --- /dev/null +++ b/app/Livewire/Project/Database/Dragonfly/StatusInfo.php @@ -0,0 +1,26 @@ +currentTeam()->id; return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } - public function refreshStatus(): void - { - $this->database->refresh(); - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - public function mount() { try { @@ -86,12 +62,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -99,7 +69,7 @@ public function mount() protected function rules(): array { - $baseRules = [ + return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'keydbConf' => 'nullable|string', @@ -112,13 +82,8 @@ protected function rules(): array 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', 'isLogDrainEnabled' => 'nullable|boolean', - 'enable_ssl' => 'boolean', ]; - - return $baseRules; } protected function messages(): array @@ -154,11 +119,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->database->enable_ssl = $this->enable_ssl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -171,9 +132,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->enable_ssl = $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } } @@ -222,6 +180,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -233,6 +192,7 @@ public function instantSave() public function databaseProxyStopped() { $this->syncData(); + $this->dispatch('databaseUpdated'); } public function submit() @@ -248,6 +208,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -259,65 +220,6 @@ public function submit() } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates() - ->where('is_ca_certificate', true) - ->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Keydb/StatusInfo.php b/app/Livewire/Project/Database/Keydb/StatusInfo.php new file mode 100644 index 000000000..1e87461cd --- /dev/null +++ b/app/Livewire/Project/Database/Keydb/StatusInfo.php @@ -0,0 +1,26 @@ +currentTeam()->id; - - return [ - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', - ]; - } - - public function refreshStatus(): void - { - $this->database->refresh(); - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - protected function rules(): array { return [ @@ -105,7 +72,6 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', ]; } @@ -144,7 +110,6 @@ protected function messages(): array 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Options', - 'enableSsl' => 'Enable SSL', ]; public function mount() @@ -158,12 +123,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -187,11 +146,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -207,9 +162,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -245,6 +197,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -281,6 +234,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -289,63 +243,6 @@ public function instantSave() } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mariadb/StatusInfo.php b/app/Livewire/Project/Database/Mariadb/StatusInfo.php new file mode 100644 index 000000000..c6fda37b6 --- /dev/null +++ b/app/Livewire/Project/Database/Mariadb/StatusInfo.php @@ -0,0 +1,21 @@ +currentTeam()->id; - - return [ - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', - ]; - } - - public function refreshStatus(): void - { - $this->database->refresh(); - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - protected function rules(): array { return [ @@ -102,8 +67,6 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', - 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-full', ]; } @@ -123,7 +86,6 @@ protected function messages(): array 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.', ] ); } @@ -141,8 +103,6 @@ protected function messages(): array 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -156,12 +116,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -184,12 +138,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -204,10 +153,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->sslMode = $this->database->ssl_mode; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -246,6 +191,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -282,6 +228,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -290,68 +237,6 @@ public function instantSave() } } - public function updatedSslMode() - { - $this->instantSaveSSL(); - } - - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mongodb/StatusInfo.php b/app/Livewire/Project/Database/Mongodb/StatusInfo.php new file mode 100644 index 000000000..a92a682c9 --- /dev/null +++ b/app/Livewire/Project/Database/Mongodb/StatusInfo.php @@ -0,0 +1,51 @@ + ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'], + 'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'], + 'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'], + 'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'], + ]; + } + + protected function sslModeHelper(): string + { + return 'Choose the SSL verification mode for MongoDB connections'; + } + + protected function afterRefresh(): void + { + $this->sslMode = $this->database->ssl_mode; + } + + protected function applyExtraSslAttributes(): void + { + $this->database->ssl_mode = $this->sslMode; + } + + public function updatedSslMode(): void + { + $this->instantSaveSSL(); + } +} diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 6e1e55b3f..6b88d735d 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -4,14 +4,11 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneMysql; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -50,38 +47,6 @@ class General extends Component public ?string $customDockerRunOptions = null; - public bool $enableSsl = false; - - public ?string $sslMode = null; - - public ?string $db_url = null; - - public ?string $db_url_public = null; - - public ?Carbon $certificateValidUntil = null; - - public function getListeners() - { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', - ]; - } - - public function refreshStatus(): void - { - $this->database->refresh(); - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - protected function rules(): array { return [ @@ -107,8 +72,6 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', - 'sslMode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY', ]; } @@ -129,7 +92,6 @@ protected function messages(): array 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.', ] ); } @@ -148,8 +110,6 @@ protected function messages(): array 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -163,12 +123,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -192,12 +146,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -213,10 +162,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->sslMode = $this->database->ssl_mode; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -252,6 +197,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -288,6 +234,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -296,68 +243,6 @@ public function instantSave() } } - public function updatedSslMode() - { - $this->instantSaveSSL(); - } - - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mysql/StatusInfo.php b/app/Livewire/Project/Database/Mysql/StatusInfo.php new file mode 100644 index 000000000..5fbbc1583 --- /dev/null +++ b/app/Livewire/Project/Database/Mysql/StatusInfo.php @@ -0,0 +1,51 @@ + ['title' => 'Prefer secure connections', 'label' => 'Prefer (secure)'], + 'REQUIRED' => ['title' => 'Require secure connections', 'label' => 'Require (secure)'], + 'VERIFY_CA' => ['title' => 'Verify CA certificate', 'label' => 'Verify CA (secure)'], + 'VERIFY_IDENTITY' => ['title' => 'Verify full certificate', 'label' => 'Verify Full (secure)'], + ]; + } + + protected function sslModeHelper(): string + { + return 'Choose the SSL verification mode for MySQL connections'; + } + + protected function afterRefresh(): void + { + $this->sslMode = $this->database->ssl_mode; + } + + protected function applyExtraSslAttributes(): void + { + $this->database->ssl_mode = $this->sslMode; + } + + public function updatedSslMode(): void + { + $this->instantSaveSSL(); + } +} diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 1b36ac28a..4e89e8b62 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -4,14 +4,11 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandalonePostgresql; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -54,43 +51,14 @@ class General extends Component public ?string $customDockerRunOptions = null; - public bool $enableSsl = false; - - public ?string $sslMode = null; - public string $new_filename; public string $new_content; - public ?string $db_url = null; - - public ?string $db_url_public = null; - - public ?Carbon $certificateValidUntil = null; - - public function getListeners() - { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', - 'save_init_script', - 'delete_init_script', - ]; - } - - public function refreshStatus(): void - { - $this->database->refresh(); - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } + protected $listeners = [ + 'save_init_script', + 'delete_init_script', + ]; protected function rules(): array { @@ -117,8 +85,6 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', - 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full', ]; } @@ -138,7 +104,6 @@ protected function messages(): array 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.', ] ); } @@ -159,8 +124,6 @@ protected function messages(): array 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -174,12 +137,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -205,12 +162,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -228,10 +180,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->sslMode = $this->database->ssl_mode; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -254,68 +202,6 @@ public function instantSaveAdvanced() } } - public function updatedSslMode() - { - $this->instantSaveSSL(); - } - - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function instantSave() { try { @@ -341,6 +227,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -504,6 +391,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { diff --git a/app/Livewire/Project/Database/Postgresql/StatusInfo.php b/app/Livewire/Project/Database/Postgresql/StatusInfo.php new file mode 100644 index 000000000..cc27b61bb --- /dev/null +++ b/app/Livewire/Project/Database/Postgresql/StatusInfo.php @@ -0,0 +1,52 @@ + ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'], + 'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'], + 'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'], + 'verify-ca' => ['title' => 'Verify CA certificate', 'label' => 'verify-ca (secure)'], + 'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'], + ]; + } + + protected function sslModeHelper(): string + { + return 'Choose the SSL verification mode for PostgreSQL connections'; + } + + protected function afterRefresh(): void + { + $this->sslMode = $this->database->ssl_mode; + } + + protected function applyExtraSslAttributes(): void + { + $this->database->ssl_mode = $this->sslMode; + } + + public function updatedSslMode(): void + { + $this->instantSaveSSL(); + } +} diff --git a/app/Livewire/Project/Database/Redis/StatusInfo.php b/app/Livewire/Project/Database/Redis/StatusInfo.php index 183ed936f..2e784e2c0 100644 --- a/app/Livewire/Project/Database/Redis/StatusInfo.php +++ b/app/Livewire/Project/Database/Redis/StatusInfo.php @@ -2,115 +2,20 @@ namespace App\Livewire\Project\Database\Redis; -use App\Helpers\SslHelper; use App\Models\StandaloneRedis; -use Carbon\Carbon; -use Exception; +use App\Traits\HasDatabaseStatusInfo; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class StatusInfo extends Component { use AuthorizesRequests; + use HasDatabaseStatusInfo; public StandaloneRedis $database; - public bool $enableSsl = false; - - public ?Carbon $certificateValidUntil = null; - - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - - public function getListeners() + protected function databaseLabel(): string { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - 'databaseUpdated' => 'refresh', - ]; - } - - public function mount(): void - { - $this->refresh(); - } - - public function refresh(): void - { - $this->database->refresh(); - $this->enableSsl = (bool) $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - - public function instantSaveSSL(): void - { - try { - $this->authorize('update', $this->database); - $this->database->enable_ssl = $this->enableSsl; - $this->database->save(); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - - public function regenerateSslCertificate(): void - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $server = $this->database->destination->server; - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $server->generateCaCertificate(); - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->refresh(); - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - - public function render() - { - return view('livewire.project.database.redis.status-info'); + return 'Redis'; } } diff --git a/app/Traits/HasDatabaseStatusInfo.php b/app/Traits/HasDatabaseStatusInfo.php new file mode 100644 index 000000000..98c939b7e --- /dev/null +++ b/app/Traits/HasDatabaseStatusInfo.php @@ -0,0 +1,164 @@ +currentTeam()->id; + + return [ + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + 'databaseUpdated' => 'refresh', + ]; + } + + public function mount(): void + { + $this->refresh(); + } + + public function refresh(): void + { + $this->database->refresh(); + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + if ($this->supportsSsl()) { + $this->enableSsl = (bool) $this->database->enable_ssl; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + $this->afterRefresh(); + } + } + + /** + * Hook for subclasses with extra status-derived properties (e.g. sslMode). + */ + protected function afterRefresh(): void {} + + public function instantSaveSSL(): void + { + try { + $this->authorize('update', $this->database); + $this->database->enable_ssl = $this->enableSsl; + $this->applyExtraSslAttributes(); + $this->database->save(); + $this->dispatch('success', 'SSL configuration updated.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + /** + * Hook for subclasses with additional SSL columns to persist (e.g. ssl_mode). + */ + protected function applyExtraSslAttributes(): void {} + + public function regenerateSslCertificate(): void + { + try { + $this->authorize('update', $this->database); + + $existingCert = $this->database->sslCertificates()->first(); + + if (! $existingCert) { + $this->dispatch('error', 'No existing SSL certificate found for this database.'); + + return; + } + + $server = $this->database->destination->server; + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); + + if (! $caCert) { + $server->generateCaCertificate(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + + SslHelper::generateSslCertificate( + commonName: $existingCert->common_name, + subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], + resourceType: $existingCert->resource_type, + resourceId: $existingCert->resource_id, + serverId: $existingCert->server_id, + caCert: $caCert->ssl_certificate, + caKey: $caCert->ssl_private_key, + configurationDir: $existingCert->configuration_dir, + mountPath: $existingCert->mount_path, + isPemKeyFileRequired: true, + ); + + $this->refresh(); + $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.project.database.status-info', [ + 'label' => $this->databaseLabel(), + 'supportsSsl' => $this->supportsSsl(), + 'sslModeOptions' => $this->sslModeOptions(), + 'sslModeHelper' => $this->sslModeHelper(), + 'showPublicUrlPlaceholder' => $this->showPublicUrlPlaceholder(), + 'isExited' => str($this->database->status)->contains('exited'), + ]); + } +} diff --git a/resources/views/components/database-status-info.blade.php b/resources/views/components/database-status-info.blade.php new file mode 100644 index 000000000..a7c8dade1 --- /dev/null +++ b/resources/views/components/database-status-info.blade.php @@ -0,0 +1,94 @@ +@props([ + 'database', + 'label', + 'dbUrl' => null, + 'dbUrlPublic' => null, + 'supportsSsl' => true, + 'enableSsl' => false, + 'sslMode' => null, + 'sslModeOptions' => null, + 'sslModeHelper' => null, + 'certificateValidUntil' => null, + 'isExited' => false, + 'showPublicUrlPlaceholder' => false, +]) + +@php + $urlHelper = 'If you change the user/password/port, this could be different. This is with the default values.'; +@endphp + +
+ + @if ($dbUrlPublic) + + @elseif ($showPublicUrlPlaceholder) + + @endif + + @if ($supportsSsl) +
+
+
+

SSL Configuration

+ @if ($enableSsl && $certificateValidUntil) + + @endif +
+
+ @if ($enableSsl && $certificateValidUntil) + Valid until: + @if (now()->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired + @elseif(now()->addDays(30)->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring + soon + @else + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} + @endif + + @endif +
+
+ @if ($isExited) + + @else + + @endif +
+ @if ($sslModeOptions && $enableSsl) +
+ @if ($isExited) + + @foreach ($sslModeOptions as $value => $option) + + @endforeach + + @else + + @foreach ($sslModeOptions as $value => $option) + + @endforeach + + @endif +
+ @endif +
+
+ @endif +
diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php index 9283172ad..acba65442 100644 --- a/resources/views/livewire/project/database/clickhouse/general.blade.php +++ b/resources/views/livewire/project/database/clickhouse/general.blade.php @@ -41,19 +41,8 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - @if ($dbUrlPublic) - - @else - - @endif
+
diff --git a/resources/views/livewire/project/database/dragonfly/general.blade.php b/resources/views/livewire/project/database/dragonfly/general.blade.php index ce46e47dd..7f217f0cc 100644 --- a/resources/views/livewire/project/database/dragonfly/general.blade.php +++ b/resources/views/livewire/project/database/dragonfly/general.blade.php @@ -37,60 +37,8 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - - @if ($dbUrlPublic) - - @else - - @endif -
-
-
-
-

SSL Configuration

- @if ($database->enable_ssl && $certificateValidUntil) - - @endif -
-
- @if ($database->enable_ssl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
+
diff --git a/resources/views/livewire/project/database/keydb/general.blade.php b/resources/views/livewire/project/database/keydb/general.blade.php index ee3f8fd0c..fa241dec2 100644 --- a/resources/views/livewire/project/database/keydb/general.blade.php +++ b/resources/views/livewire/project/database/keydb/general.blade.php @@ -38,59 +38,8 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - @if ($dbUrlPublic) - - @else - - @endif -
-
-
-
-

SSL Configuration

- @if ($database->enable_ssl && $certificateValidUntil) - - @endif -
-
- @if ($database->enable_ssl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
+
diff --git a/resources/views/livewire/project/database/mariadb/general.blade.php b/resources/views/livewire/project/database/mariadb/general.blade.php index 1154124d1..b29b3e81e 100644 --- a/resources/views/livewire/project/database/mariadb/general.blade.php +++ b/resources/views/livewire/project/database/mariadb/general.blade.php @@ -61,59 +61,9 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - @if ($db_url_public) - - @endif
-
-
-
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
-
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
-
+
diff --git a/resources/views/livewire/project/database/mongodb/general.blade.php b/resources/views/livewire/project/database/mongodb/general.blade.php index e9e5d621d..c1ec60219 100644 --- a/resources/views/livewire/project/database/mongodb/general.blade.php +++ b/resources/views/livewire/project/database/mongodb/general.blade.php @@ -50,85 +50,10 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - @if ($db_url_public) - - @endif
+
-
-
-

SSL Configuration

- @if ($enableSsl) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
-
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
- @if ($enableSsl) -
- @if (str($database->status)->contains('exited')) - - - - - - - @else - - - - - - - @endif -
- @endif -
-
diff --git a/resources/views/livewire/project/database/mysql/general.blade.php b/resources/views/livewire/project/database/mysql/general.blade.php index bb3916ec8..e90885e7c 100644 --- a/resources/views/livewire/project/database/mysql/general.blade.php +++ b/resources/views/livewire/project/database/mysql/general.blade.php @@ -56,81 +56,9 @@
- - @if ($db_url_public) - - @endif
-
-
-
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
-
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
- @if ($enableSsl) -
- @if (str($database->status)->contains('exited')) - - - - - - - @else - - - - - - - @endif -
- @endif -
-
+
diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php index 9c956f5b3..ab6f6ed88 100644 --- a/resources/views/livewire/project/database/postgresql/general.blade.php +++ b/resources/views/livewire/project/database/postgresql/general.blade.php @@ -68,114 +68,38 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - - @if ($db_url_public) - - @endif
+
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - +

Proxy

+ + @if (data_get($database, 'is_public')) + + Proxy Logs + + + + Logs + @endif
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif +
+ +
+
+ + +
-
-
- @if ($database->isExited()) - - @else - - @endif -
- @if ($enableSsl) -
- @if ($database->isExited()) - - - - - - - - @else - - - - - - - - @endif -
- @endif - -
-
-

Proxy

- - @if (data_get($database, 'is_public')) - - Proxy Logs - - - - Logs - - @endif -
-
- -
-
- - -
-
- -
- -
-
+
diff --git a/resources/views/livewire/project/database/redis/status-info.blade.php b/resources/views/livewire/project/database/redis/status-info.blade.php deleted file mode 100644 index 9f329504c..000000000 --- a/resources/views/livewire/project/database/redis/status-info.blade.php +++ /dev/null @@ -1,51 +0,0 @@ -
- - @if ($dbUrlPublic) - - @endif -
-
-
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
-
-
diff --git a/resources/views/livewire/project/database/status-info.blade.php b/resources/views/livewire/project/database/status-info.blade.php new file mode 100644 index 000000000..7107b3daf --- /dev/null +++ b/resources/views/livewire/project/database/status-info.blade.php @@ -0,0 +1,6 @@ +
+ +
diff --git a/tests/Feature/DatabaseSslStatusRefreshTest.php b/tests/Feature/DatabaseSslStatusRefreshTest.php index b663213a5..7b0e4c0a3 100644 --- a/tests/Feature/DatabaseSslStatusRefreshTest.php +++ b/tests/Feature/DatabaseSslStatusRefreshTest.php @@ -1,11 +1,19 @@ not->toHaveKey("echo-private:team.{$this->team->id},ServiceStatusChanged"); })->with('database-general-forms-without-broadcasts'); -it('routes status broadcasts to refreshStatus, never to a handler that re-syncs inputs', function (string $componentClass) { - // Regression guard for coolify#6062 / #6354 / #9695: - // The form may listen to broadcasts, but only to a narrow handler (refreshStatus) - // that touches display-only properties. Routing to `refresh` or `$refresh` would - // re-sync every input property from the DB and wipe in-progress typing. - $listeners = resolveLivewireListeners(app($componentClass)); - - $databaseStatusKey = "echo-private:user.{$this->user->id},DatabaseStatusChanged"; - $serviceCheckedKey = "echo-private:team.{$this->team->id},ServiceChecked"; - - expect($listeners[$databaseStatusKey] ?? null)->toBe('refreshStatus') - ->and($listeners[$serviceCheckedKey] ?? null)->toBe('refreshStatus'); -})->with('database-general-forms-with-narrow-refresh'); - function resolveLivewireListeners(object $component): array { // Livewire's HandlesEvents trait declares getListeners() as protected, @@ -101,7 +99,7 @@ function resolveLivewireListeners(object $component): array ->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked"); })->with('database-status-info-components'); -it('reloads the mysql database model when refresh is called directly so ssl controls follow the latest status', function () { +it('reloads the mysql status-info model when refresh is called so ssl controls follow the latest status', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $destination = StandaloneDocker::where('server_id', $server->id)->first(); $project = Project::factory()->create(['team_id' => $this->team->id]); @@ -122,7 +120,7 @@ function resolveLivewireListeners(object $component): array 'destination_type' => $destination->getMorphClass(), ]); - $component = Livewire::test(MysqlGeneral::class, ['database' => $database]) + $component = Livewire::test(MysqlStatusInfo::class, ['database' => $database]) ->assertDontSee('Database should be stopped to change this settings.'); $database->fill(['status' => 'running:healthy'])->save(); @@ -161,36 +159,6 @@ function resolveLivewireListeners(object $component): array ->and($component->get('ip'))->toBe('203.0.113.42'); }); -it('preserves typed input on the postgres form when refreshStatus runs', function () { - $server = Server::factory()->create(['team_id' => $this->team->id]); - $destination = StandaloneDocker::where('server_id', $server->id)->first(); - $project = Project::factory()->create(['team_id' => $this->team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - - $database = StandalonePostgresql::create([ - 'name' => 'persisted-name', - 'image' => 'postgres:16', - 'postgres_user' => 'postgres', - 'postgres_password' => 'password', - 'postgres_db' => 'postgres', - 'status' => 'exited:unhealthy', - 'enable_ssl' => false, - 'is_log_drain_enabled' => false, - 'environment_id' => $environment->id, - 'destination_id' => $destination->id, - 'destination_type' => $destination->getMorphClass(), - ]); - - $component = Livewire::test(PostgresqlGeneral::class, ['database' => $database]) - ->set('name', 'user-was-typing-here') - ->set('portsMappings', '5433:5432'); - - $component->call('refreshStatus'); - - expect($component->get('name'))->toBe('user-was-typing-here') - ->and($component->get('portsMappings'))->toBe('5433:5432'); -}); - it('shows the redis ssl gate hint after the sibling is refreshed', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $destination = StandaloneDocker::where('server_id', $server->id)->first(); From 7a3fcd37d5b5057523aa5107d986f209b29d5403 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Thu, 21 May 2026 10:24:49 +0000 Subject: [PATCH 18/90] fix(livewire): scope DatabaseProxyStopped to proxy fields, harden status trait Clickhouse, Dragonfly, and Keydb still called syncData() inside the DatabaseProxyStopped broadcast handler, clobbering in-progress edits to name/description/credentials. Refresh only is_public/public_port/ public_port_timeout instead, matching the pattern used elsewhere. Also null-guard HasDatabaseStatusInfo::getListeners() against an absent Auth::user()/currentTeam(), add explicit return types on getListeners() and render(), and convert inline comments in the SSL refresh test to a PHPDoc block. --- .../Project/Database/Clickhouse/General.php | 7 +++-- .../Project/Database/Dragonfly/General.php | 7 +++-- .../Project/Database/Keydb/General.php | 7 +++-- app/Traits/HasDatabaseStatusInfo.php | 26 ++++++++++++------- .../Feature/DatabaseSslStatusRefreshTest.php | 8 +++--- 5 files changed, 37 insertions(+), 18 deletions(-) diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index b5c0ffff4..857300926 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -192,9 +192,12 @@ public function instantSave() } } - public function databaseProxyStopped() + public function databaseProxyStopped(): void { - $this->syncData(); + $this->database->refresh(); + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->dispatch('databaseUpdated'); } diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 5f57693b1..01a474761 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -184,9 +184,12 @@ public function instantSave() } } - public function databaseProxyStopped() + public function databaseProxyStopped(): void { - $this->syncData(); + $this->database->refresh(); + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->dispatch('databaseUpdated'); } diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 1c5c828a3..6031cb7ac 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -189,9 +189,12 @@ public function instantSave() } } - public function databaseProxyStopped() + public function databaseProxyStopped(): void { - $this->syncData(); + $this->database->refresh(); + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->dispatch('databaseUpdated'); } diff --git a/app/Traits/HasDatabaseStatusInfo.php b/app/Traits/HasDatabaseStatusInfo.php index 98c939b7e..e46cccf0c 100644 --- a/app/Traits/HasDatabaseStatusInfo.php +++ b/app/Traits/HasDatabaseStatusInfo.php @@ -5,6 +5,7 @@ use App\Helpers\SslHelper; use Carbon\Carbon; use Exception; +use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Auth; /** @@ -51,16 +52,23 @@ protected function showPublicUrlPlaceholder(): bool return false; } - public function getListeners() + public function getListeners(): array { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; + $listeners = ['databaseUpdated' => 'refresh']; - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - 'databaseUpdated' => 'refresh', - ]; + $user = Auth::user(); + if (! $user) { + return $listeners; + } + + $listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refresh'; + + $team = $user->currentTeam(); + if ($team) { + $listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refresh'; + } + + return $listeners; } public function mount(): void @@ -150,7 +158,7 @@ public function regenerateSslCertificate(): void } } - public function render() + public function render(): View { return view('livewire.project.database.status-info', [ 'label' => $this->databaseLabel(), diff --git a/tests/Feature/DatabaseSslStatusRefreshTest.php b/tests/Feature/DatabaseSslStatusRefreshTest.php index 7b0e4c0a3..7efb03789 100644 --- a/tests/Feature/DatabaseSslStatusRefreshTest.php +++ b/tests/Feature/DatabaseSslStatusRefreshTest.php @@ -78,11 +78,13 @@ ->not->toHaveKey("echo-private:team.{$this->team->id},ServiceStatusChanged"); })->with('database-general-forms-without-broadcasts'); +/** + * Resolve a Livewire component's listeners regardless of whether the subclass + * exposes getListeners() publicly or only declares a $listeners array — the + * HandlesEvents trait keeps getListeners() protected by default. + */ function resolveLivewireListeners(object $component): array { - // Livewire's HandlesEvents trait declares getListeners() as protected, - // so subclasses that override it as public are callable directly, but - // subclasses that rely on $listeners are not. Reflection handles both. $method = new ReflectionMethod($component, 'getListeners'); $method->setAccessible(true); From ab30b99b6e625f235db4d83396a92f1821b6d11f Mon Sep 17 00:00:00 2001 From: Victor Gomez Date: Thu, 21 May 2026 09:36:52 -0400 Subject: [PATCH 19/90] Add Healthchecks.io as a service --- public/svgs/healthchecks.webp | Bin 0 -> 5222 bytes templates/compose/healthchecks.yaml | 32 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 public/svgs/healthchecks.webp create mode 100644 templates/compose/healthchecks.yaml diff --git a/public/svgs/healthchecks.webp b/public/svgs/healthchecks.webp new file mode 100644 index 0000000000000000000000000000000000000000..003f05f3f9d662904752815fa9cdde40d71b401f GIT binary patch literal 5222 zcmZWsXEYo@*WT4@^k|ESU0t;3ZH36L5=#(7l&Fg(T67lCqOTSudRc@-@3A373DMhX z(GsGQ=zQ{?_x<(FxpU6FXYS0MdCqfYp3#SEXqYns08bx2Ko}upjVS;C0PRg50RA&o zZ3HZl8~~sO5u@-h^5EI?U4Z%*qUp%jzpqjQ4{?U3b*L;%5kvoRfgtKxp)uDJei z)p2&cdHv5xh|rP;^ScKUq(b2voY*UaRRESKPxoT-$L{10S5ulJc61D32`kio#IKH1 z$Kf(7**&+^Ur^63(z#R@i^##;)jeJ}UiA?#J@3#u4t_%a5(db=k5~o7lR)yp*@kcj zCDX;T;n%$Pr5wkxdNTRs+p_H>0>pZZuWM}8^-jE5RSl3tsd;_1_a~ageKR2`1vnvh zIDZ+~%gElV3X%UHeDnQK`5e=j;vl|37%%-T=5yZEhNqldOYm*0syt8k1hyym;4Ds8=KWk$x|#e&HP>9koa$t(;Yedqhg%l5Xmwrr&dbmHXW zs(6=pm#c-ceL8N{;Gk7@nZI6wjaU-0a`%^i4=Bl~0{quDKIpG}Y4mMxxs9&aEP2j` zSLp~8T9_V=e@g^&!edeRRjt;sSkipM-86Lb8^QS&kX(*4$bfa8X>SVYw<~kLo8JG< z5ywLJa=K^?_Op`jNTQ?+k7c%WKNWbc%q3^RuiFnC0KU`teXFeT%(9wFYW9}wb`5qm zCEeY+xsE>1YXU_#_QVFVCurI50ykG|Z>v5>-HU2flLK4a>*pZ7*fDV`(XM+2U{M|l z463H$U!MSia_8GD1a}RT>;RcEcyFgiVpCtV&>OeIdYqMU;u9v&SATZi+FopuoO~Mh zly9`-0coFmMLv-PtTBtOJxOnoeOfvp!FehID6+_G&QO_MLH}hpH(%FT0^-JJlm`^)_0BBmt-WTlx4Mpjz z>{;ERh91W#BJK%thti2&y|nzq7B&3WSVqa?Q+o(iyFAL)MUr;8jj%7aULv39(9LOD zA4Q}O@)wCOQy#|i0}c?z6o9`?7pfa?;3yM*`qQ;63xxWY=&wc zWN_?}xoOF*A|Oz8^niHwD7FYZW!!Se4Y64MsFe?X5}1^-CG{m=`7AY=5AoBVCi{IK z&vWZyn-v>4=SF{DrebWWJf*$_B*8OPK1ne)@DY7|reaNal0c$;jX3>A*L0&X`B9Q{ z?ERyB4xSL@(bOau*Gwl&hpR_o^g=X^t{80EzsmDG{9Y1`H7l#0<|@w&IJymt{0e)d z1^$;*pnj15{JnZk5{71*B`JWPNHz3THKsbCjz0q~3a>MHy8cz+W@6 zBOrVbkb-vj8d(4lxzp31O;5Xhg0LxkaI;1NJuTCTgWg@>Nc=z7=J5mDXhkl_9l^aM z#AD4AaLP@fNnaicPWs7^5`f)fyzQ%;v#Lg%L8+-+mkegI8A=8dL%3EU@bHrX_;9+yq;2<j$ZKtFc&*dDFmsrW=$vWv})~-*Wbg`sbYjnAq)u_W0`Nh zcJ{IDPpkL68SO!8f%}mA#>iuyyHn)uO2#XiX~!lwkV{=lZ49dN_iC}>9-X9i?{{Cpdk1iWIGJxR+PeBsjamP z<1I!YZH?(3y{#LqjOH;g-5*L}{H@8}>^u5K$oe6htLkiVZbecLWwa^+fNaYz8l}-c zH;k8f0NuhcJMn3T&EoHDlkOAE{f;neh#u40VIH$T>Xx!5^VdSzbdvQm9@)sw|BNwMPy}4ce zgMS&T35_p*xICr_KhF@5RnF#oOwgLe>xj+we$QanrCXHE#|nhA1NlDFX^T&BzrRw-I<;9L%)0gp$}g?V&mr^H_QGw z8%xwc$W?yn?dy?EYZv$T6|Xirq6K&%+_^5iYWn&vAH@_XirB|S~e zxDAhrcq5HQ(ZdkD%1@4Z^@iHtAuD=>x~+M$Nu;n&q9Pb@!nsUyCy@mgV^L;WP>=OlPP<1j8GtV+XO%$AbdtgxsLI=yJsQVKV4*}gQKIgB0Nf=pK853rhmu61W3KZ z+EiF(EFYjif`Ki&a=k&Hn>l=WNT&02`~TKjg0Ey>Hkjs`6D+3$v;^Lc?d?3b|3u~b zGg30?`|dnxp`_1LB_oZlw>_T)4ZBFtNki3dd{)*+3vtW$b{Y>gzVf}+D9&hUk!?O9 zm6yZHP}3~VEi7L8d0!8v`-DG^j?;>wyh={I`oYW$BgON+J$PbY=J?b+u$>DABh$hPM8 zC9nDqT)|gmLC#;RIWt?Wly{FK4=%2_<-n1vO?a-s5s(i2gKU0tlhr#uypG53b5v$z z^Fs#tXU9HqG=pMCD33+g%SqRHSa!CY`X@~;CEsAafBr-!A2otxj z3VL%D@R!{Z1jZV9J@U9$xbB%-T`jRY61tbtgU=FV-oM*^-->I@2Ri0yFbw;BV-V8? ztdkpS00vH&$1Z3zm!)-sG5wPst^X57Hwx)pIq*iQ-ND{SHH1Cj9Z>Ka@EcG$36`%V zBdL;K#4bCqM^A3=a$(WTJeb?V;vk6=RypXuhBjw(4VX*{Q_dc4j9m_GS9#2HyS$^( zy~tP;^Nzh3yZlsr!jkS=Yy=+%76;WYfw{`NHmuy|`{cBN7< zc2=i5Z%!e;&M)EU9NJ?)B_dmyJPgmIuojFvb%LwCk34|J#K)e^;4Xa zm)<%%tw?a$wlQ+mqmlDTum4OB+OaRReie${v@`sJ5o*>9i}MFR3ab+)BnT;$Iqto5 zjg-%-=H*t&zV)5_!l}PgqiQMDoy{OSqfnm_uYlMcY;lkwYxKkcsTZew?c}{al6-fgEeUB=SDFuQ5^AD~d<){> zK3+BB;29Vc8USFkf5`cvXBJ&gb(oz;ubnvXU>QEFU$s(}!JSsP#g5y4N%W5{cAsnw zY@qrPllQJzeP)?jUaf!^-1&6vfZ#sHh(__Pj?JSPLmMO=X&x0O(#k=f2(@p6ON6tBrYld?)WEKZ%5)I`a z@BLbATsVGNU$${LZJyQy)e>B~HQyC+(Ym>9;;!-|;9P4txy1_u^Hy0x z-9nUOgQMdJr-cTkPuS3Wxc7$QY2ASqi>4hX^-%ItPi8i8$gn}bD`8Ax;4t^;+=3PS z($Ain_4NP`aD@M*&bMWixdmAO!vu4USJR{XvmekU+Pec$ABj(-ql|i#)bHuQwTW9u z99kKLA-^OO)rLn^?o^SC%Ad2Jr&fI|dVjKP)56BtzOkPF0i^!PV(-#9p~Dc-sVhqV zt=tH0N1-uYNmRx3o_0j6u7c;+h5{Gy^iGo919PF4vCa<^J01>kD%$L>TEqU&A}RR1 zL#?=Lu7_lZ!^C+tGnMYApvbIyIa|AZwhpE}jcUAJ`9N?#A}r8ToS9j#S(lS2pj`U= zq~dg!)8<2t=ol=y4Kwyvx4HA&gG~$0G*J!GJ>01R*=uK(yAxf(%HM3!1L@mV41Thtv_;;hKK&zVS`f6-m&i^jA>+!;D@WF^Ow@q||S~qI2$H2m9*kD1ZOqp0g#NX=o|9K-)ex5T;rWFAAt6ci`A zx+ne8a4d_o--%naaGFS?4g5rdQ+_L|+^jSXi&04Lc94Aqa6{h;H&?$t_0eV~_8A`# zbP}XiUF=(zMn+~wS3~MD+ND!Jzho|dk~;t<_Q2C>uOPTwk*+*yxl<`1>3D-c# zOq_CgX{|b>GGwBeTJT~NW8}7Qhf`YH?RwU$H{zS(e#C54MoWS%%)T~)ZFP4dSmTgX z0oVu)39D)%$$IrCgOHHL&SsR2Wh15_%A%hujQWrk2O70xPA8w`pJh2*Wej>b_a0s%tKbIvEFzUlk=H5OXFapEo4$?>Yc}j9p#daa1mPr`l zRWc2euWvLbOylCzPoLA>e&|x8$~+_QDHp`3G_U<+xp`6l*ZvPw66XZ~ literal 0 HcmV?d00001 diff --git a/templates/compose/healthchecks.yaml b/templates/compose/healthchecks.yaml new file mode 100644 index 000000000..b88c6ef40 --- /dev/null +++ b/templates/compose/healthchecks.yaml @@ -0,0 +1,32 @@ +# documentation: https://healthchecks.io/docs +# slogan: Healthchecks is a cron job monitoring service. It listens for HTTP requests and email messages from your cron jobs and scheduled tasks. +# category: monitoring +# tags: monitoring, status, performance, web, services, applications, real-time +# logo: svgs/healthchecks.webp +# port: 80000 + +services: + healthchecks: + image: "healthchecks/healthchecks:v4.2" + container_name: healthchecks + environment: + - SERVICE_URL_HEALTHCHECKS_8000 + - DB=sqlite + - DB_NAME=/data/hc.sqlite + - "REGISTRATION_OPEN=${REGISTRATION_OPEN:-True}" + - "DEBUG=${DEBUG:-False}" + - "SECRET_KEY=${SECRET_KEY:?${SERVICE_PASSWORD_64_HEALTHCHECKS}}" + - "SITE_ROOT=${SERVICE_URL_HEALTHCHECKS}" + - "DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL:-fixme-email-address-here}" + - "EMAIL_HOST=${EMAIL_HOST:-my-smtp-server-here.com}" + - "EMAIL_PORT=${EMAIL_PORT:-465}" + - "EMAIL_HOST_USER=${EMAIL_HOST_USER:-my_username}" + - "EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-mypassword}" + - "EMAIL_USE_SSL=${EMAIL_USE_SSL:-True}" + - "EMAIL_USE_TLS=${EMAIL_USE_TLS:-False}" + volumes: + - "healthchecks-data:/data" + restart: unless-stopped + +volumes: + healthchecks-data: null From aa4c229607048fe58c9e0ab41e1e28082d0c4b77 Mon Sep 17 00:00:00 2001 From: Victor Gomez Date: Thu, 21 May 2026 11:19:31 -0400 Subject: [PATCH 20/90] Ensure proper URL is being used in SITE_ROOT --- templates/compose/healthchecks.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/templates/compose/healthchecks.yaml b/templates/compose/healthchecks.yaml index b88c6ef40..fd1168aaf 100644 --- a/templates/compose/healthchecks.yaml +++ b/templates/compose/healthchecks.yaml @@ -11,8 +11,10 @@ services: container_name: healthchecks environment: - SERVICE_URL_HEALTHCHECKS_8000 + - SERVICE_URL_HEALTHCHECKS - DB=sqlite - DB_NAME=/data/hc.sqlite + - "ALLOWED_HOSTS=${ALOWED_HOSTS:-*}" - "REGISTRATION_OPEN=${REGISTRATION_OPEN:-True}" - "DEBUG=${DEBUG:-False}" - "SECRET_KEY=${SECRET_KEY:?${SERVICE_PASSWORD_64_HEALTHCHECKS}}" From 85abbf2555eaf59b2aefe971253c3c2f4c403844 Mon Sep 17 00:00:00 2001 From: Victor Gomez <73703121+viticodotdev@users.noreply.github.com> Date: Thu, 21 May 2026 11:46:51 -0400 Subject: [PATCH 21/90] Update templates/compose/healthchecks.yaml Co-authored-by: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> --- templates/compose/healthchecks.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/templates/compose/healthchecks.yaml b/templates/compose/healthchecks.yaml index fd1168aaf..3de245ca2 100644 --- a/templates/compose/healthchecks.yaml +++ b/templates/compose/healthchecks.yaml @@ -11,7 +11,6 @@ services: container_name: healthchecks environment: - SERVICE_URL_HEALTHCHECKS_8000 - - SERVICE_URL_HEALTHCHECKS - DB=sqlite - DB_NAME=/data/hc.sqlite - "ALLOWED_HOSTS=${ALOWED_HOSTS:-*}" From 101926ca3577ac836e9cacdffbb9ff9ca52d42a4 Mon Sep 17 00:00:00 2001 From: Victor Gomez <73703121+viticodotdev@users.noreply.github.com> Date: Thu, 21 May 2026 11:47:04 -0400 Subject: [PATCH 22/90] Update templates/compose/healthchecks.yaml Co-authored-by: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> --- templates/compose/healthchecks.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/templates/compose/healthchecks.yaml b/templates/compose/healthchecks.yaml index 3de245ca2..b6ff8cf4e 100644 --- a/templates/compose/healthchecks.yaml +++ b/templates/compose/healthchecks.yaml @@ -29,5 +29,3 @@ services: - "healthchecks-data:/data" restart: unless-stopped -volumes: - healthchecks-data: null From afe5a03aeb42dc268748e0334a202de026ffe84e Mon Sep 17 00:00:00 2001 From: Victor Gomez <73703121+viticodotdev@users.noreply.github.com> Date: Thu, 21 May 2026 11:47:21 -0400 Subject: [PATCH 23/90] Update templates/compose/healthchecks.yaml Co-authored-by: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> --- templates/compose/healthchecks.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/templates/compose/healthchecks.yaml b/templates/compose/healthchecks.yaml index b6ff8cf4e..15c284770 100644 --- a/templates/compose/healthchecks.yaml +++ b/templates/compose/healthchecks.yaml @@ -27,5 +27,4 @@ services: - "EMAIL_USE_TLS=${EMAIL_USE_TLS:-False}" volumes: - "healthchecks-data:/data" - restart: unless-stopped From d415f3a3d1e60243281676d90bc7e8de8976f0e2 Mon Sep 17 00:00:00 2001 From: Firsak <31401457+Firsak@users.noreply.github.com> Date: Fri, 22 May 2026 11:06:32 +0200 Subject: [PATCH 24/90] fix(team): prevent 500 after deleting the current team When a user deletes their current team, the session and cache still reference the just-deleted team. `refreshSession()` then resolves that stale team via `currentTeam()`, calls `Team::find()` (which returns null because the row is gone) and dereferences `$team->id`, leaving the session without a current team. The subsequent redirect to the team page assigns the now-null `currentTeam()` to the non-nullable `Team $team` property in `Team\Index::mount()`, throwing a TypeError and producing an HTTP 500. Guard `refreshSession()` against a deleted current team: fall back to any team the user still belongs to, and if none remain, clear the stale session reference instead of dereferencing null. Co-Authored-By: Claude Opus 4.7 (1M context) --- bootstrap/helpers/shared.php | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 860b550dd..011c86744 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -353,14 +353,30 @@ function showBoarding(): bool function refreshSession(?Team $team = null): void { if (! $team) { - if (Auth::user()->currentTeam()) { - $team = Team::find(Auth::user()->currentTeam()->id); - } else { - $team = User::find(Auth::id())->teams->first(); + $currentTeam = Auth::user()->currentTeam(); + if ($currentTeam) { + // currentTeam() can resolve a stale (just-deleted) team from the + // session/cache, so Team::find() may still return null here. + $team = Team::find($currentTeam->id); + } + if (! $team) { + // Fall back to any team the user still belongs to. + $team = User::find(Auth::id())->teams()->first(); } } + // Clear old cache key format for backwards compatibility Cache::forget('team:'.Auth::id()); + + if (! $team) { + // The user has no team left (e.g. just deleted their current team and + // belongs to no other): clear the stale session reference instead of + // dereferencing null. + session()->forget('currentTeam'); + + return; + } + // Use new cache key format that includes team ID Cache::forget('user:'.Auth::id().':team:'.$team->id); Cache::remember('user:'.Auth::id().':team:'.$team->id, 3600, function () use ($team) { From a4d75ff0e28b8a9a5a9f4ea58ef32a87b7b4ec24 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 23 May 2026 13:06:36 +0200 Subject: [PATCH 25/90] fix(backups): validate S3 storage before backup scheduling Prevent scheduled database backups from enabling S3 uploads without a valid team-owned storage configuration, and preserve the previous S3 storage ID in missing-storage error messages. Add coverage for backup edit/create validation and S3 upload failure messaging. --- app/Jobs/DatabaseBackupJob.php | 4 +- app/Livewire/Project/Database/BackupEdit.php | 16 ++- .../Database/CreateScheduledBackup.php | 11 +- app/Models/S3Storage.php | 1 + .../ScheduledDatabaseBackupExecution.php | 1 + tests/Feature/BackupEditValidationTest.php | 85 +++++++++++++++ .../CreateScheduledBackupValidationTest.php | 102 ++++++++++++++++++ tests/Feature/DatabaseBackupJobTest.php | 42 ++++++++ 8 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 tests/Feature/BackupEditValidationTest.php create mode 100644 tests/Feature/CreateScheduledBackupValidationTest.php diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index bd31ab0c3..64e900b49 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -668,12 +668,14 @@ private function calculate_size() private function upload_to_s3(): void { if (is_null($this->s3)) { + $previousS3StorageId = $this->backup->s3_storage_id; + $this->backup->update([ 'save_s3' => false, 's3_storage_id' => null, ]); - throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.'); + throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($previousS3StorageId ?? 'null').'). S3 backup has been disabled for this schedule.'); } try { diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index a18022882..ef106a65f 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; +use App\Models\ServiceDatabase; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; @@ -144,7 +145,7 @@ public function delete($password, $selectedActions = []) try { $server = null; - if ($this->backup->database instanceof \App\Models\ServiceDatabase) { + if ($this->backup->database instanceof ServiceDatabase) { $server = $this->backup->database->service->destination->server; } elseif ($this->backup->database->destination && $this->backup->database->destination->server) { $server = $this->backup->database->destination->server; @@ -170,7 +171,7 @@ public function delete($password, $selectedActions = []) $this->backup->delete(); - if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($this->backup->database->getMorphClass() === ServiceDatabase::class) { $serviceDatabase = $this->backup->database; return redirect()->route('project.service.database.backups', [ @@ -182,7 +183,7 @@ public function delete($password, $selectedActions = []) } else { return redirect()->route('project.database.backup.index', $this->parameters); } - } catch (\Exception $e) { + } catch (Exception $e) { $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); return handleError($e, $this); @@ -207,6 +208,13 @@ private function customValidate() $this->backup->s3_storage_id = null; } + // S3 backup cannot be enabled without a valid S3 storage owned by the team + $availableS3Ids = collect($this->s3s)->pluck('id'); + if ($this->backup->save_s3 && ! $availableS3Ids->contains($this->backup->s3_storage_id)) { + $this->backup->save_s3 = $this->saveS3 = false; + $this->backup->s3_storage_id = $this->s3StorageId = null; + } + // Validate that disable_local_backup can only be true when S3 backup is enabled if ($this->backup->disable_local_backup && ! $this->backup->save_s3) { $this->backup->disable_local_backup = $this->disableLocalBackup = false; @@ -214,7 +222,7 @@ private function customValidate() $isValid = validate_cron_expression($this->backup->frequency); if (! $isValid) { - throw new \Exception('Invalid Cron / Human expression'); + throw new Exception('Invalid Cron / Human expression'); } $this->validate(); } diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index 7f807afe2..bb8b8b9c7 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; +use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; @@ -48,6 +49,14 @@ public function submit() $this->validate(); + if ($this->saveToS3) { + if (is_null($this->s3StorageId) || ! $this->definedS3s->contains('id', $this->s3StorageId)) { + $this->dispatch('error', 'Please select a valid S3 storage to enable S3 backups.'); + + return; + } + } + $isValid = validate_cron_expression($this->frequency); if (! $isValid) { $this->dispatch('error', 'Invalid Cron / Human expression.'); @@ -74,7 +83,7 @@ public function submit() } $databaseBackup = ScheduledDatabaseBackup::create($payload); - if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($this->database->getMorphClass() === ServiceDatabase::class) { $this->dispatch('refreshScheduledBackups', $databaseBackup->id); } else { $this->dispatch('refreshScheduledBackups'); diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 3f6ee51cc..fca74170d 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -15,6 +15,7 @@ class S3Storage extends BaseModel use HasFactory, HasSafeStringAttribute; protected $fillable = [ + 'team_id', 'name', 'description', 'region', diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php index 51ad46de9..1d5f5f9ce 100644 --- a/app/Models/ScheduledDatabaseBackupExecution.php +++ b/app/Models/ScheduledDatabaseBackupExecution.php @@ -23,6 +23,7 @@ class ScheduledDatabaseBackupExecution extends BaseModel protected function casts(): array { return [ + 'size' => 'integer', 's3_uploaded' => 'boolean', 'local_storage_deleted' => 'boolean', 's3_storage_deleted' => 'boolean', diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php new file mode 100644 index 000000000..8894f0f69 --- /dev/null +++ b/tests/Feature/BackupEditValidationTest.php @@ -0,0 +1,85 @@ +create(['team_id' => $team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $database = StandalonePostgresql::create([ + 'name' => 'pg-backup-edit-validation', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + return ScheduledDatabaseBackup::create(array_merge([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => null, + 'database_type' => $database->getMorphClass(), + 'database_id' => $database->id, + 'team_id' => $team->id, + ], $overrides)); +} + +beforeEach(function () { + if (InstanceSettings::find(0) === null) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->save(); + } + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('disables S3 backup when saved without a selected S3 storage', function () { + $backup = createBackupForEditValidationTest($this->team); + + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s]) + ->call('submit') + ->assertDispatched('success'); + + $backup->refresh(); + expect($backup->save_s3)->toBeFalsy(); + expect($backup->s3_storage_id)->toBeNull(); +}); + +it('cascades to disabling local backup deletion when S3 is force-disabled', function () { + $backup = createBackupForEditValidationTest($this->team, [ + 'disable_local_backup' => true, + ]); + + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s]) + ->call('submit') + ->assertDispatched('success'); + + $backup->refresh(); + expect($backup->save_s3)->toBeFalsy(); + expect($backup->s3_storage_id)->toBeNull(); + expect($backup->disable_local_backup)->toBeFalsy(); +}); diff --git a/tests/Feature/CreateScheduledBackupValidationTest.php b/tests/Feature/CreateScheduledBackupValidationTest.php new file mode 100644 index 000000000..a167511e2 --- /dev/null +++ b/tests/Feature/CreateScheduledBackupValidationTest.php @@ -0,0 +1,102 @@ +create(['team_id' => $team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + return StandalonePostgresql::create([ + 'name' => 'pg-scheduled-backup-validation', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +function createS3StorageForTeam(Team $team, string $name = 'Test S3'): S3Storage +{ + return S3Storage::create([ + 'name' => $name, + 'region' => 'us-east-1', + 'key' => 'test-key', + 'secret' => 'test-secret', + 'bucket' => 'test-bucket', + 'endpoint' => 'https://s3.example.com', + 'is_usable' => true, + 'team_id' => $team->id, + ]); +} + +beforeEach(function () { + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('rejects enabling S3 backup without a selected S3 storage', function () { + $database = createDatabaseForScheduledBackupTest($this->team); + + Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', '0 0 * * *') + ->set('saveToS3', true) + ->set('s3StorageId', null) + ->call('submit') + ->assertDispatched('error'); + + expect(ScheduledDatabaseBackup::count())->toBe(0); +}); + +it('rejects an S3 storage not owned by the current team', function () { + $database = createDatabaseForScheduledBackupTest($this->team); + + $foreignS3 = createS3StorageForTeam(Team::factory()->create(), 'Foreign S3'); + + Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', '0 0 * * *') + ->set('saveToS3', true) + ->set('s3StorageId', $foreignS3->id) + ->call('submit') + ->assertDispatched('error'); + + expect(ScheduledDatabaseBackup::count())->toBe(0); +}); + +it('creates a scheduled backup with a valid team-owned S3 storage', function () { + $database = createDatabaseForScheduledBackupTest($this->team); + $s3 = createS3StorageForTeam($this->team); + + Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', '0 0 * * *') + ->set('saveToS3', true) + ->set('s3StorageId', $s3->id) + ->call('submit') + ->assertDispatched('refreshScheduledBackups'); + + $backup = ScheduledDatabaseBackup::first(); + expect($backup)->not->toBeNull(); + expect($backup->save_s3)->toBeTruthy(); + expect($backup->s3_storage_id)->toBe($s3->id); +}); diff --git a/tests/Feature/DatabaseBackupJobTest.php b/tests/Feature/DatabaseBackupJobTest.php index 05cb21f12..2d549c1ca 100644 --- a/tests/Feature/DatabaseBackupJobTest.php +++ b/tests/Feature/DatabaseBackupJobTest.php @@ -66,6 +66,48 @@ expect($backup->s3_storage_id)->toBeNull(); }); +test('upload_to_s3 exception message reports the previous s3 storage id', function () { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => 12345, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => Team::factory()->create()->id, + ]); + + $job = new DatabaseBackupJob($backup); + + $reflection = new ReflectionClass($job); + $reflection->getProperty('s3')->setValue($job, null); + + expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job)) + ->toThrow(Exception::class, 'S3 storage ID: 12345'); + + $backup->refresh(); + expect($backup->save_s3)->toBeFalsy(); + expect($backup->s3_storage_id)->toBeNull(); +}); + +test('upload_to_s3 exception message reports null when no previous s3 storage id exists', function () { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => null, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => Team::factory()->create()->id, + ]); + + $job = new DatabaseBackupJob($backup); + + $reflection = new ReflectionClass($job); + $reflection->getProperty('s3')->setValue($job, null); + + expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job)) + ->toThrow(Exception::class, 'S3 storage ID: null'); +}); + test('deleting s3 storage disables s3 on linked backups', function () { $team = Team::factory()->create(); From 33e172ac24aa43ae89f1f93783f87abd77e6673d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 23 May 2026 21:06:22 +0200 Subject: [PATCH 26/90] fix(backups): revalidate S3 storage on scheduled backup submit Check the selected S3 storage against the database at submit time so stale Livewire state cannot schedule backups with storage that was reassigned or marked unusable after the component mounted. --- .../Database/CreateScheduledBackup.php | 9 ++++- .../CreateScheduledBackupValidationTest.php | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index bb8b8b9c7..7384adcff 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Database; +use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -50,7 +51,13 @@ public function submit() $this->validate(); if ($this->saveToS3) { - if (is_null($this->s3StorageId) || ! $this->definedS3s->contains('id', $this->s3StorageId)) { + $s3StorageExists = ! is_null($this->s3StorageId) + && S3Storage::where('team_id', currentTeam()->id) + ->where('is_usable', true) + ->whereKey($this->s3StorageId) + ->exists(); + + if (! $s3StorageExists) { $this->dispatch('error', 'Please select a valid S3 storage to enable S3 backups.'); return; diff --git a/tests/Feature/CreateScheduledBackupValidationTest.php b/tests/Feature/CreateScheduledBackupValidationTest.php index a167511e2..4b5eec24c 100644 --- a/tests/Feature/CreateScheduledBackupValidationTest.php +++ b/tests/Feature/CreateScheduledBackupValidationTest.php @@ -84,6 +84,42 @@ function createS3StorageForTeam(Team $team, string $name = 'Test S3'): S3Storage expect(ScheduledDatabaseBackup::count())->toBe(0); }); +it('rejects an S3 storage that is reassigned after the component is mounted', function () { + $database = createDatabaseForScheduledBackupTest($this->team); + $s3 = createS3StorageForTeam($this->team); + + $component = Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', '0 0 * * *') + ->set('saveToS3', true) + ->set('s3StorageId', $s3->id); + + $s3->update(['team_id' => Team::factory()->create()->id]); + + $component + ->call('submit') + ->assertDispatched('error'); + + expect(ScheduledDatabaseBackup::count())->toBe(0); +}); + +it('rejects an S3 storage that becomes unusable after the component is mounted', function () { + $database = createDatabaseForScheduledBackupTest($this->team); + $s3 = createS3StorageForTeam($this->team); + + $component = Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', '0 0 * * *') + ->set('saveToS3', true) + ->set('s3StorageId', $s3->id); + + $s3->update(['is_usable' => false]); + + $component + ->call('submit') + ->assertDispatched('error'); + + expect(ScheduledDatabaseBackup::count())->toBe(0); +}); + it('creates a scheduled backup with a valid team-owned S3 storage', function () { $database = createDatabaseForScheduledBackupTest($this->team); $s3 = createS3StorageForTeam($this->team); From 4ccec6b2101786fde966e2e8eed2ca27a21c9ea5 Mon Sep 17 00:00:00 2001 From: Victor Gomez <73703121+viticodotdev@users.noreply.github.com> Date: Mon, 25 May 2026 13:06:59 -0400 Subject: [PATCH 27/90] Fix typo in ALLOWED_HOSTS environment variable --- templates/compose/healthchecks.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/healthchecks.yaml b/templates/compose/healthchecks.yaml index 15c284770..246fabb7b 100644 --- a/templates/compose/healthchecks.yaml +++ b/templates/compose/healthchecks.yaml @@ -13,7 +13,7 @@ services: - SERVICE_URL_HEALTHCHECKS_8000 - DB=sqlite - DB_NAME=/data/hc.sqlite - - "ALLOWED_HOSTS=${ALOWED_HOSTS:-*}" + - "ALLOWED_HOSTS=${ALLOWED_HOSTS:-*}" - "REGISTRATION_OPEN=${REGISTRATION_OPEN:-True}" - "DEBUG=${DEBUG:-False}" - "SECRET_KEY=${SECRET_KEY:?${SERVICE_PASSWORD_64_HEALTHCHECKS}}" From 98b36c1ff79fde236eba2765350fc5665d546081 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 07:27:47 +0000 Subject: [PATCH 28/90] chore(deps): bump ws from 8.19.0 to 8.20.1 in /docker/coolify-realtime Bumps [ws](https://github.com/websockets/ws) from 8.19.0 to 8.20.1. - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/compare/8.19.0...8.20.1) --- updated-dependencies: - dependency-name: ws dependency-version: 8.20.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- docker/coolify-realtime/package-lock.json | 8 ++++---- docker/coolify-realtime/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json index 5c6fa94aa..cdb29bffa 100644 --- a/docker/coolify-realtime/package-lock.json +++ b/docker/coolify-realtime/package-lock.json @@ -10,7 +10,7 @@ "cookie": "1.1.1", "dotenv": "17.3.1", "node-pty": "1.1.0", - "ws": "8.19.0" + "ws": "8.20.1" } }, "node_modules/@xterm/addon-fit": { @@ -70,9 +70,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/docker/coolify-realtime/package.json b/docker/coolify-realtime/package.json index 25bf786a8..9128c0c3f 100644 --- a/docker/coolify-realtime/package.json +++ b/docker/coolify-realtime/package.json @@ -7,6 +7,6 @@ "cookie": "1.1.1", "dotenv": "17.3.1", "node-pty": "1.1.0", - "ws": "8.19.0" + "ws": "8.20.1" } } From 885f6eb124d416a4071b750a240f2d92ded11513 Mon Sep 17 00:00:00 2001 From: Gabriel Peralta Date: Wed, 27 May 2026 09:31:29 -0300 Subject: [PATCH 29/90] Chatwoot: Support allowlisted private API inbox webhooks Self-hosted installations can now opt SafeFetch into private-network access after SSRF hardening. The default remains unchanged: private IP destinations are blocked unless the instance owner explicitly enables private-network requests with SAFE_FETCH_ALLOW_PRIVATE_NETWORK=true This is a breaking change if you use latest tag and have evolution-api or similar deployed on coolify alongside chatwoot. --- templates/compose/chatwoot.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/compose/chatwoot.yaml b/templates/compose/chatwoot.yaml index 407e82bb3..87aaa2c05 100644 --- a/templates/compose/chatwoot.yaml +++ b/templates/compose/chatwoot.yaml @@ -38,6 +38,7 @@ services: - SMTP_USERNAME=${CHATWOOT_SMTP_USERNAME} - SMTP_PASSWORD=${CHATWOOT_SMTP_PASSWORD} - ACTIVE_STORAGE_SERVICE=${ACTIVE_STORAGE_SERVICE:-local} + - SAFE_FETCH_ALLOW_PRIVATE_NETWORK=${SAFE_FETCH_ALLOW_PRIVATE_NETWORK:-false} entrypoint: docker/entrypoints/rails.sh command: sh -c "bundle exec rails db:chatwoot_prepare && bundle exec rails s -p 3000 -b 0.0.0.0" volumes: From c35d28f99b61cd856cce1740a17a271bed7f1c33 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 28 May 2026 17:13:18 +0200 Subject: [PATCH 30/90] fix(database): guard proxy listeners without a team --- .../Project/Database/Clickhouse/General.php | 13 ++++++++++--- app/Livewire/Project/Database/Dragonfly/General.php | 13 ++++++++++--- app/Livewire/Project/Database/Keydb/General.php | 13 ++++++++++--- .../views/components/database-status-info.blade.php | 4 ++-- 4 files changed, 32 insertions(+), 11 deletions(-) diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 857300926..694674326 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -42,12 +42,19 @@ class General extends Component public bool $isLogDrainEnabled = false; - public function getListeners() + public function getListeners(): array { - $teamId = Auth::user()->currentTeam()->id; + $user = Auth::user(); + if (! $user) { + return []; + } + $team = $user->currentTeam(); + if (! $team) { + return []; + } return [ - "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', + "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped', ]; } diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 01a474761..f196b9dfb 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -40,12 +40,19 @@ class General extends Component public bool $isLogDrainEnabled = false; - public function getListeners() + public function getListeners(): array { - $teamId = Auth::user()->currentTeam()->id; + $user = Auth::user(); + if (! $user) { + return []; + } + $team = $user->currentTeam(); + if (! $team) { + return []; + } return [ - "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', + "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped', ]; } diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 6031cb7ac..974803e8d 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -42,12 +42,19 @@ class General extends Component public bool $isLogDrainEnabled = false; - public function getListeners() + public function getListeners(): array { - $teamId = Auth::user()->currentTeam()->id; + $user = Auth::user(); + if (! $user) { + return []; + } + $team = $user->currentTeam(); + if (! $team) { + return []; + } return [ - "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', + "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped', ]; } diff --git a/resources/views/components/database-status-info.blade.php b/resources/views/components/database-status-info.blade.php index a7c8dade1..4a9de3ca5 100644 --- a/resources/views/components/database-status-info.blade.php +++ b/resources/views/components/database-status-info.blade.php @@ -63,7 +63,7 @@ @else @endif
@@ -79,7 +79,7 @@ @else @foreach ($sslModeOptions as $value => $option) From dd8a0d501de740c1b6ead3789d5661b25615d19c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 28 May 2026 17:31:11 +0200 Subject: [PATCH 31/90] fix(s3): cap connection checks at 15 seconds Return a friendly timeout error for failed S3 endpoint checks while preserving the original exception as the previous throwable. --- app/Models/S3Storage.php | 27 ++++++++++++- tests/Unit/S3StorageTest.php | 73 ++++++++++++++++++++++++++++++++++-- 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 3f6ee51cc..90232a151 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -14,6 +14,10 @@ class S3Storage extends BaseModel { use HasFactory, HasSafeStringAttribute; + private const CONNECTION_TIMEOUT_SECONDS = 15; + + private const REQUEST_TIMEOUT_SECONDS = 15; + protected $fillable = [ 'name', 'description', @@ -157,6 +161,10 @@ public function testConnection(bool $shouldSave = false) 'bucket' => $this['bucket'], 'endpoint' => $this['endpoint'], 'use_path_style_endpoint' => true, + 'http' => [ + 'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS, + 'timeout' => self::REQUEST_TIMEOUT_SECONDS, + ], ]); // Test the connection by listing files with ListObjectsV2 (S3) $disk->files(); @@ -164,11 +172,12 @@ public function testConnection(bool $shouldSave = false) $this->unusable_email_sent = false; $this->is_usable = true; } catch (\Throwable $e) { + $exception = $this->toUserFriendlyConnectionException($e); $this->is_usable = false; if ($this->unusable_email_sent === false && is_transactional_emails_enabled()) { $mail = new MailMessage; $mail->subject('Coolify: S3 Storage Connection Error'); - $mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $e->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]); + $mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $exception->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]); // Load the team with its members and their roles explicitly $team = $this->team()->with(['members' => function ($query) { @@ -183,11 +192,25 @@ public function testConnection(bool $shouldSave = false) $this->unusable_email_sent = true; } - throw $e; + throw $exception; } finally { if ($shouldSave) { $this->save(); } } } + + private function toUserFriendlyConnectionException(\Throwable $exception): \Throwable + { + $message = str($exception->getMessage())->lower(); + + if ($message->contains(['timed out', 'timeout', 'connection refused', 'could not resolve', 'curl error 28'])) { + return new \RuntimeException( + 'Could not connect to the S3 endpoint within 15 seconds. Please verify the endpoint, bucket, credentials, region, and network/firewall settings.', + previous: $exception, + ); + } + + return $exception; + } } diff --git a/tests/Unit/S3StorageTest.php b/tests/Unit/S3StorageTest.php index 6709f381d..ddf390443 100644 --- a/tests/Unit/S3StorageTest.php +++ b/tests/Unit/S3StorageTest.php @@ -1,6 +1,10 @@ awsUrl())->toBe('https://minio.example.com:9000/backups'); }); -test('S3Storage model is guarded correctly', function () { +test('S3Storage model fillable attributes are configured correctly', function () { $s3Storage = new S3Storage; - // The model should have $guarded = [] which means everything is fillable - expect($s3Storage->getGuarded())->toBe([]); + expect($s3Storage->getFillable())->toBe([ + 'name', + 'description', + 'region', + 'key', + 'secret', + 'bucket', + 'endpoint', + 'is_usable', + 'unusable_email_sent', + ]); +}); + +test('S3Storage connection validation uses short s3 client timeouts', function () { + $disk = Mockery::mock(); + $disk->expects('files')->once()->andReturn([]); + + Storage::expects('build') + ->once() + ->with(Mockery::on(function (array $config) { + expect($config['http']['connect_timeout'])->toBe(15); + expect($config['http']['timeout'])->toBe(15); + + return true; + })) + ->andReturn($disk); + + $s3Storage = new S3Storage; + $s3Storage->setRawAttributes([ + 'name' => 'Test S3', + 'region' => 'us-east-1', + 'key' => null, + 'secret' => null, + 'bucket' => 'test-bucket', + 'endpoint' => 'https://s3.amazonaws.com', + ]); + + $s3Storage->testConnection(); + + expect($s3Storage->is_usable)->toBeTrue(); +}); + +test('S3Storage connection validation returns friendly timeout error', function () { + $disk = Mockery::mock(); + $disk->expects('files') + ->once() + ->andThrow(new RuntimeException('cURL error 28: Operation timed out after 15000 milliseconds')); + + Storage::expects('build')->once()->andReturn($disk); + + $s3Storage = new S3Storage; + $s3Storage->setRawAttributes([ + 'name' => 'Test S3', + 'region' => 'us-east-1', + 'key' => null, + 'secret' => null, + 'bucket' => 'test-bucket', + 'endpoint' => 'https://s3.amazonaws.com', + 'unusable_email_sent' => true, + ]); + + expect(fn () => $s3Storage->testConnection()) + ->toThrow(RuntimeException::class, 'Could not connect to the S3 endpoint within 15 seconds.'); + + expect($s3Storage->is_usable)->toBeFalse(); }); From 322bf7c1b2a060873827b83b0a35221f8058dc8d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 28 May 2026 19:30:12 +0200 Subject: [PATCH 32/90] refactor(database): split import form into Livewire child Extract the database import form into its own component and add realtime status refresh components for application server badges and service resource cards. --- .../Project/Application/ServerStatusBadge.php | 41 + app/Livewire/Project/Database/Import.php | 853 ++---------------- app/Livewire/Project/Database/ImportForm.php | 821 +++++++++++++++++ app/Livewire/Project/Service/ResourceCard.php | 66 ++ .../application/configuration.blade.php | 16 +- .../application/server-status-badge.blade.php | 17 + .../project/database/import-form.blade.php | 228 +++++ .../project/database/import.blade.php | 237 +---- .../project/service/configuration.blade.php | 130 +-- .../project/service/resource-card.blade.php | 77 ++ .../Feature/DatabaseSslStatusRefreshTest.php | 99 ++ 11 files changed, 1450 insertions(+), 1135 deletions(-) create mode 100644 app/Livewire/Project/Application/ServerStatusBadge.php create mode 100644 app/Livewire/Project/Database/ImportForm.php create mode 100644 app/Livewire/Project/Service/ResourceCard.php create mode 100644 resources/views/livewire/project/application/server-status-badge.blade.php create mode 100644 resources/views/livewire/project/database/import-form.blade.php create mode 100644 resources/views/livewire/project/service/resource-card.blade.php diff --git a/app/Livewire/Project/Application/ServerStatusBadge.php b/app/Livewire/Project/Application/ServerStatusBadge.php new file mode 100644 index 000000000..459271e28 --- /dev/null +++ b/app/Livewire/Project/Application/ServerStatusBadge.php @@ -0,0 +1,41 @@ +currentTeam(); + if (! $team) { + return []; + } + + return [ + "echo-private:team.{$team->id},ServiceStatusChanged" => 'refreshStatus', + "echo-private:team.{$team->id},ServiceChecked" => 'refreshStatus', + ]; + } + + public function refreshStatus(): void + { + $this->application->refresh(); + } + + public function render(): View + { + return view('livewire.project.application.server-status-badge'); + } +} diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 304de62ef..ea04658cf 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -2,22 +2,14 @@ namespace App\Livewire\Project\Database; -use App\Models\S3Storage; -use App\Models\Server; -use App\Models\Service; use App\Models\ServiceDatabase; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; -use App\Models\StandaloneMariadb; -use App\Models\StandaloneMongodb; -use App\Models\StandaloneMysql; -use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; -use App\Support\ValidationPatterns; +use Illuminate\Contracts\View\View; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Storage; -use Livewire\Attributes\Computed; +use Illuminate\Support\Facades\Auth; use Livewire\Attributes\Locked; use Livewire\Component; @@ -25,797 +17,134 @@ class Import extends Component { use AuthorizesRequests; - /** - * Validate that a string is safe for use as an S3 bucket name. - * Allows alphanumerics, dots, dashes, and underscores. - */ - private function validateBucketName(string $bucket): bool - { - return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1; - } - - /** - * Validate that a string is safe for use as an S3 path. - * Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters. - */ - private function validateS3Path(string $path): bool - { - // Must not be empty - if (empty($path)) { - return false; - } - - // Must not contain dangerous shell metacharacters or command injection patterns - $dangerousPatterns = [ - '..', // Directory traversal - '$(', // Command substitution - '`', // Backtick command substitution - '|', // Pipe - ';', // Command separator - '&', // Background/AND - '>', // Redirect - '<', // Redirect - "\n", // Newline - "\r", // Carriage return - "\0", // Null byte - "'", // Single quote - '"', // Double quote - '\\', // Backslash - ]; - - foreach ($dangerousPatterns as $pattern) { - if (str_contains($path, $pattern)) { - return false; - } - } - - // Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at - return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1; - } - - /** - * Validate that a string is safe for use as a file path on the server. - */ - private function validateServerPath(string $path): bool - { - // Must be an absolute path - if (! str_starts_with($path, '/')) { - return false; - } - - // Must not contain dangerous shell metacharacters or command injection patterns - $dangerousPatterns = [ - '..', // Directory traversal - '$(', // Command substitution - '`', // Backtick command substitution - '|', // Pipe - ';', // Command separator - '&', // Background/AND - '>', // Redirect - '<', // Redirect - "\n", // Newline - "\r", // Carriage return - "\0", // Null byte - "'", // Single quote - '"', // Double quote - '\\', // Backslash - ]; - - foreach ($dangerousPatterns as $pattern) { - if (str_contains($path, $pattern)) { - return false; - } - } - - // Allow alphanumerics, dots, dashes, underscores, slashes, and spaces - return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1; - } - - public bool $unsupported = false; - - // Store IDs instead of models for proper Livewire serialization #[Locked] public ?int $resourceId = null; #[Locked] public ?string $resourceType = null; - #[Locked] - public ?int $serverId = null; - - // View-friendly properties to avoid computed property access in Blade - #[Locked] - public string $resourceUuid = ''; - public string $resourceStatus = ''; - #[Locked] - public string $resourceDbType = ''; + public string $resourceUuid = ''; - public array $parameters = []; + public bool $unsupported = false; - public array $containers = []; - - public bool $scpInProgress = false; - - public bool $importRunning = false; - - public ?string $filename = null; - - public ?string $filesize = null; - - public bool $isUploading = false; - - public int $progress = 0; - - public bool $error = false; - - #[Locked] - public string $container; - - public array $importCommands = []; - - public bool $dumpAll = false; - - public string $restoreCommandText = ''; - - public string $customLocation = ''; - - public ?int $activityId = null; - - public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; - - public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; - - public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; - - public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='; - - // S3 Restore properties - public array $availableS3Storages = []; - - public ?int $s3StorageId = null; - - public string $s3Path = ''; - - public ?int $s3FileSize = null; - - #[Computed] - public function resource() + public function getListeners(): array { - if ($this->resourceId === null || $this->resourceType === null) { - return null; + $listeners = ['databaseUpdated' => 'refreshStatus']; + + $user = Auth::user(); + if (! $user) { + return $listeners; } - return $this->resourceType::find($this->resourceId); - } + $listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refreshStatus'; - #[Computed] - public function server() - { - if ($this->serverId === null) { - return null; + $team = $user->currentTeam(); + if ($team) { + $listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refreshStatus'; } - return Server::ownedByCurrentTeam()->find($this->serverId); + return $listeners; } - protected $listeners = [ - 'slideOverClosed' => 'resetActivityId', - ]; - - public function resetActivityId() + public function mount(): void { - $this->activityId = null; - } - - public function mount() - { - $this->parameters = get_route_parameters(); - $this->getContainers(); - $this->loadAvailableS3Storages(); - } - - public function updatedDumpAll($value) - { - $morphClass = $this->resource->getMorphClass(); - - // Handle ServiceDatabase by checking the database type - if ($morphClass === ServiceDatabase::class) { - $dbType = $this->resource->databaseType(); - if (str_contains($dbType, 'mysql')) { - $morphClass = 'mysql'; - } elseif (str_contains($dbType, 'mariadb')) { - $morphClass = 'mariadb'; - } elseif (str_contains($dbType, 'postgres')) { - $morphClass = 'postgresql'; - } - } - - switch ($morphClass) { - case StandaloneMariadb::class: - case 'mariadb': - if ($value === true) { - $this->mariadbRestoreCommand = <<<'EOD' -for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do - mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true -done && \ -mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \ -mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \ -(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default} -EOD; - $this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}'; - } else { - $this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; - } - break; - case StandaloneMysql::class: - case 'mysql': - if ($value === true) { - $this->mysqlRestoreCommand = <<<'EOD' -for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do - mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true -done && \ -mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \ -mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \ -(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default} -EOD; - $this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}'; - } else { - $this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; - } - break; - case StandalonePostgresql::class: - case 'postgresql': - if ($value === true) { - $this->postgresqlRestoreCommand = <<<'EOD' -psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \ -psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \ -createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} -EOD; - $this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; - } else { - $this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; - } - break; - } - - } - - public function getContainers() - { - $this->containers = []; - $teamId = data_get(auth()->user()->currentTeam(), 'id'); - - // Try to find resource by route parameter - $databaseUuid = data_get($this->parameters, 'database_uuid'); - $stackServiceUuid = data_get($this->parameters, 'stack_service_uuid'); - - $resource = null; - if ($databaseUuid) { - // Standalone database route - $resource = getResourceByUuid($databaseUuid, $teamId); - if (is_null($resource)) { - abort(404); - } - } elseif ($stackServiceUuid) { - // ServiceDatabase route - look up the service database - $serviceUuid = data_get($this->parameters, 'service_uuid'); - $project = currentTeam() - ->projects() - ->select('id', 'uuid', 'team_id') - ->where('uuid', data_get($this->parameters, 'project_uuid')) - ->firstOrFail(); - $environment = $project->environments() - ->select('id', 'uuid', 'name', 'project_id') - ->where('uuid', data_get($this->parameters, 'environment_uuid')) - ->firstOrFail(); - $service = $environment->services()->whereUuid($serviceUuid)->firstOrFail(); - $resource = $service->databases()->whereUuid($stackServiceUuid)->first(); - if (is_null($resource)) { - abort(404); - } - } else { - abort(404); - } - + $resource = $this->resolveResourceFromRoute(); $this->authorize('view', $resource); - // Store IDs for Livewire serialization $this->resourceId = $resource->id; $this->resourceType = get_class($resource); - // Store view-friendly properties + $this->refreshStatus(); + } + + public function refreshStatus(): void + { + $resource = $this->resolveStoredResource(); + $this->authorize('view', $resource); + + $resource->refresh(); + $this->resourceUuid = $resource->uuid; $this->resourceStatus = $resource->status ?? ''; + $this->unsupported = $this->isUnsupportedResource($resource); + } - // Handle ServiceDatabase server access differently - if ($resource->getMorphClass() === ServiceDatabase::class) { - $server = $resource->service?->server; - if (! $server) { - abort(404, 'Server not found for this service database.'); - } - $this->serverId = $server->id; - $this->container = $resource->name.'-'.$resource->service->uuid; - $this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID + public function render(): View + { + return view('livewire.project.database.import'); + } - // Determine database type for ServiceDatabase - $dbType = $resource->databaseType(); - if (str_contains($dbType, 'postgres')) { - $this->resourceDbType = 'standalone-postgresql'; - } elseif (str_contains($dbType, 'mysql')) { - $this->resourceDbType = 'standalone-mysql'; - } elseif (str_contains($dbType, 'mariadb')) { - $this->resourceDbType = 'standalone-mariadb'; - } elseif (str_contains($dbType, 'mongo')) { - $this->resourceDbType = 'standalone-mongodb'; - } else { - $this->resourceDbType = $dbType; + private function resolveResourceFromRoute(): object + { + $parameters = get_route_parameters(); + $teamId = data_get(Auth::user()?->currentTeam(), 'id'); + $databaseUuid = data_get($parameters, 'database_uuid'); + $stackServiceUuid = data_get($parameters, 'stack_service_uuid'); + + if ($databaseUuid) { + $resource = getResourceByUuid($databaseUuid, $teamId); + if ($resource) { + return $resource; } - } else { - $server = $resource->destination?->server; - if (! $server) { - abort(404, 'Server not found for this database.'); - } - $this->serverId = $server->id; - $this->container = $resource->uuid; - $this->resourceUuid = $resource->uuid; - $this->resourceDbType = $resource->type(); + + abort(404); } - if (str($resource->status)->startsWith('running')) { - $this->containers[] = $this->container; + if ($stackServiceUuid) { + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', data_get($parameters, 'project_uuid')) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') + ->where('uuid', data_get($parameters, 'environment_uuid')) + ->firstOrFail(); + $service = $environment->services()->whereUuid(data_get($parameters, 'service_uuid'))->firstOrFail(); + $resource = $service->databases()->whereUuid($stackServiceUuid)->first(); + if ($resource) { + return $resource; + } } + abort(404); + } + + private function resolveStoredResource(): object + { + if ($this->resourceId === null || $this->resourceType === null) { + return $this->resolveResourceFromRoute(); + } + + $resource = $this->resourceType::find($this->resourceId); + if ($resource) { + return $resource; + } + + abort(404); + } + + private function isUnsupportedResource(object $resource): bool + { if ( - $resource->getMorphClass() === StandaloneRedis::class || - $resource->getMorphClass() === StandaloneKeydb::class || - $resource->getMorphClass() === StandaloneDragonfly::class || - $resource->getMorphClass() === StandaloneClickhouse::class + $resource instanceof StandaloneRedis || + $resource instanceof StandaloneKeydb || + $resource instanceof StandaloneDragonfly || + $resource instanceof StandaloneClickhouse ) { - $this->unsupported = true; + return true; } - // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) - if ($resource->getMorphClass() === ServiceDatabase::class) { + if ($resource instanceof ServiceDatabase) { $dbType = $resource->databaseType(); - if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') || - str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) { - $this->unsupported = true; - } - } - } - public function checkFile() - { - if (filled($this->customLocation)) { - // Validate the custom location to prevent command injection - if (! $this->validateServerPath($this->customLocation)) { - $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); - - return; - } - - if (! $this->server) { - $this->dispatch('error', 'Server not found. Please refresh the page.'); - - return; - } - - try { - $escapedPath = escapeshellarg($this->customLocation); - $result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false); - if (blank($result)) { - $this->dispatch('error', 'The file does not exist or has been deleted.'); - - return; - } - $this->filename = $this->customLocation; - $this->dispatch('success', 'The file exists.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - } - - public function runImport(string $password = ''): bool|string - { - if (! verifyPasswordConfirmation($password, $this)) { - return 'The provided password is incorrect.'; + return str_contains($dbType, 'redis') || + str_contains($dbType, 'keydb') || + str_contains($dbType, 'dragonfly') || + str_contains($dbType, 'clickhouse'); } - $this->authorize('update', $this->resource); - - if (! ValidationPatterns::isValidContainerName($this->container)) { - $this->dispatch('error', 'Invalid container name.'); - - return true; - } - - if ($this->filename === '') { - $this->dispatch('error', 'Please select a file to import.'); - - return true; - } - - if (! $this->server) { - $this->dispatch('error', 'Server not found. Please refresh the page.'); - - return true; - } - - try { - $this->importRunning = true; - $this->importCommands = []; - $backupFileName = "upload/{$this->resourceUuid}/restore"; - - // Check if an uploaded file exists first (takes priority over custom location) - if (Storage::exists($backupFileName)) { - $path = Storage::path($backupFileName); - $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid; - instant_scp($path, $tmpPath, $this->server); - Storage::delete($backupFileName); - $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}"; - } elseif (filled($this->customLocation)) { - // Validate the custom location to prevent command injection - if (! $this->validateServerPath($this->customLocation)) { - $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.'); - - return true; - } - $tmpPath = '/tmp/restore_'.$this->resourceUuid; - $escapedCustomLocation = escapeshellarg($this->customLocation); - $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}"; - } else { - $this->dispatch('error', 'The file does not exist or has been deleted.'); - - return true; - } - - // Copy the restore command to a script file - $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; - - $restoreCommand = $this->buildRestoreCommand($tmpPath); - - $restoreCommandBase64 = base64_encode($restoreCommand); - $this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}"; - $this->importCommands[] = "chmod +x {$scriptPath}"; - $this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}"; - - $this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'"; - $this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; - - if (! empty($this->importCommands)) { - $activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [ - 'scriptPath' => $scriptPath, - 'tmpPath' => $tmpPath, - 'container' => $this->container, - 'serverId' => $this->server->id, - ]); - - // Track the activity ID - $this->activityId = $activity->id; - - // Dispatch activity to the monitor and open slide-over - $this->dispatch('activityMonitor', $activity->id); - $this->dispatch('databaserestore'); - } - } catch (\Throwable $e) { - handleError($e, $this); - - return true; - } finally { - $this->filename = null; - $this->importCommands = []; - } - - return true; - } - - public function loadAvailableS3Storages() - { - try { - $this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description']) - ->where('is_usable', true) - ->get() - ->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description]) - ->toArray(); - } catch (\Throwable $e) { - $this->availableS3Storages = []; - } - } - - public function updatedS3Path($value) - { - // Reset validation state when path changes - $this->s3FileSize = null; - - // Ensure path starts with a slash - if ($value !== null && $value !== '') { - $this->s3Path = str($value)->trim()->start('/')->value(); - } - } - - public function updatedS3StorageId() - { - // Reset validation state when storage changes - $this->s3FileSize = null; - } - - public function checkS3File() - { - if (! $this->s3StorageId) { - $this->dispatch('error', 'Please select an S3 storage.'); - - return; - } - - if (blank($this->s3Path)) { - $this->dispatch('error', 'Please provide an S3 path.'); - - return; - } - - // Clean the path (remove leading slash if present) - $cleanPath = ltrim($this->s3Path, '/'); - - // Validate the S3 path early to prevent command injection in subsequent operations - if (! $this->validateS3Path($cleanPath)) { - $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); - - return; - } - - try { - $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); - - // Validate bucket name early - if (! $this->validateBucketName($s3Storage->bucket)) { - $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); - - return; - } - - // Test connection - $s3Storage->testConnection(); - - // Build S3 disk configuration - $disk = Storage::build([ - 'driver' => 's3', - 'region' => $s3Storage->region, - 'key' => $s3Storage->key, - 'secret' => $s3Storage->secret, - 'bucket' => $s3Storage->bucket, - 'endpoint' => $s3Storage->endpoint, - 'use_path_style_endpoint' => true, - ]); - - // Check if file exists - if (! $disk->exists($cleanPath)) { - $this->dispatch('error', 'File not found in S3. Please check the path.'); - - return; - } - - // Get file size - $this->s3FileSize = $disk->size($cleanPath); - - $this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize)); - } catch (\Throwable $e) { - $this->s3FileSize = null; - - return handleError($e, $this); - } - } - - public function restoreFromS3(string $password = ''): bool|string - { - if (! verifyPasswordConfirmation($password, $this)) { - return 'The provided password is incorrect.'; - } - - $this->authorize('update', $this->resource); - - if (! ValidationPatterns::isValidContainerName($this->container)) { - $this->dispatch('error', 'Invalid container name.'); - - return true; - } - - if (! $this->s3StorageId || blank($this->s3Path)) { - $this->dispatch('error', 'Please select S3 storage and provide a path first.'); - - return true; - } - - if (is_null($this->s3FileSize)) { - $this->dispatch('error', 'Please check the file first by clicking "Check File".'); - - return true; - } - - if (! $this->server) { - $this->dispatch('error', 'Server not found. Please refresh the page.'); - - return true; - } - - try { - $this->importRunning = true; - - $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); - - $key = $s3Storage->key; - $secret = $s3Storage->secret; - $bucket = $s3Storage->bucket; - $endpoint = $s3Storage->endpoint; - - // Validate bucket name to prevent command injection - if (! $this->validateBucketName($bucket)) { - $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); - - return true; - } - - // Clean the S3 path - $cleanPath = ltrim($this->s3Path, '/'); - - // Validate the S3 path to prevent command injection - if (! $this->validateS3Path($cleanPath)) { - $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); - - return true; - } - - // Get helper image - $helperImage = config('constants.coolify.helper_image'); - $latestVersion = getHelperVersion(); - $fullImageName = "{$helperImage}:{$latestVersion}"; - - // Get the database destination network - if ($this->resource->getMorphClass() === ServiceDatabase::class) { - $destinationNetwork = $this->resource->service->destination->network ?? 'coolify'; - } else { - $destinationNetwork = $this->resource->destination->network ?? 'coolify'; - } - - // Generate unique names for this operation - $containerName = "s3-restore-{$this->resourceUuid}"; - $helperTmpPath = '/tmp/'.basename($cleanPath); - $serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath); - $containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath); - $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; - - // Prepare all commands in sequence - $commands = []; - - // 1. Clean up any existing helper container and temp files from previous runs - $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; - $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true"; - $commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true"; - - // 2. Start helper container on the database network - $commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600"; - - // 3. Configure S3 access in helper container - $escapedEndpoint = escapeshellarg($endpoint); - $escapedKey = escapeshellarg($key); - $escapedSecret = escapeshellarg($secret); - $commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; - - // 4. Check file exists in S3 (bucket and path already validated above) - $escapedBucket = escapeshellarg($bucket); - $escapedCleanPath = escapeshellarg($cleanPath); - $escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}"); - $commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}"; - - // 5. Download from S3 to helper container (progress shown by default) - $escapedHelperTmpPath = escapeshellarg($helperTmpPath); - $commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}"; - - // 6. Copy from helper to server, then immediately to database container - $commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}"; - $commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}"; - - // 7. Cleanup helper container and server temp file immediately (no longer needed) - $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; - $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true"; - - // 8. Build and execute restore command inside database container - $restoreCommand = $this->buildRestoreCommand($containerTmpPath); - - $restoreCommandBase64 = base64_encode($restoreCommand); - $commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}"; - $commands[] = "chmod +x {$scriptPath}"; - $commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}"; - - // 9. Execute restore and cleanup temp files immediately after completion - $commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'"; - $commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; - - // Execute all commands with cleanup event (as safety net for edge cases) - $activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [ - 'containerName' => $containerName, - 'serverTmpPath' => $serverTmpPath, - 'scriptPath' => $scriptPath, - 'containerTmpPath' => $containerTmpPath, - 'container' => $this->container, - 'serverId' => $this->server->id, - ]); - - // Track the activity ID - $this->activityId = $activity->id; - - // Dispatch activity to the monitor and open slide-over - $this->dispatch('activityMonitor', $activity->id); - $this->dispatch('databaserestore'); - $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); - } catch (\Throwable $e) { - $this->importRunning = false; - handleError($e, $this); - - return true; - } - - return true; - } - - public function buildRestoreCommand(string $tmpPath): string - { - $morphClass = $this->resource->getMorphClass(); - - // Handle ServiceDatabase by checking the database type - if ($morphClass === ServiceDatabase::class) { - $dbType = $this->resource->databaseType(); - if (str_contains($dbType, 'mysql')) { - $morphClass = 'mysql'; - } elseif (str_contains($dbType, 'mariadb')) { - $morphClass = 'mariadb'; - } elseif (str_contains($dbType, 'postgres')) { - $morphClass = 'postgresql'; - } elseif (str_contains($dbType, 'mongo')) { - $morphClass = 'mongodb'; - } - } - - switch ($morphClass) { - case StandaloneMariadb::class: - case 'mariadb': - $restoreCommand = $this->mariadbRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}"; - } else { - $restoreCommand .= " < {$tmpPath}"; - } - break; - case StandaloneMysql::class: - case 'mysql': - $restoreCommand = $this->mysqlRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}"; - } else { - $restoreCommand .= " < {$tmpPath}"; - } - break; - case StandalonePostgresql::class: - case 'postgresql': - $restoreCommand = $this->postgresqlRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}"; - } else { - $restoreCommand .= " {$tmpPath}"; - } - break; - case StandaloneMongodb::class: - case 'mongodb': - $restoreCommand = $this->mongodbRestoreCommand; - if ($this->dumpAll === false) { - $restoreCommand .= "{$tmpPath}"; - } - break; - default: - $restoreCommand = ''; - } - - return $restoreCommand; + return false; } } diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php new file mode 100644 index 000000000..1d394af87 --- /dev/null +++ b/app/Livewire/Project/Database/ImportForm.php @@ -0,0 +1,821 @@ +', // Redirect + '<', // Redirect + "\n", // Newline + "\r", // Carriage return + "\0", // Null byte + "'", // Single quote + '"', // Double quote + '\\', // Backslash + ]; + + foreach ($dangerousPatterns as $pattern) { + if (str_contains($path, $pattern)) { + return false; + } + } + + // Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at + return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1; + } + + /** + * Validate that a string is safe for use as a file path on the server. + */ + private function validateServerPath(string $path): bool + { + // Must be an absolute path + if (! str_starts_with($path, '/')) { + return false; + } + + // Must not contain dangerous shell metacharacters or command injection patterns + $dangerousPatterns = [ + '..', // Directory traversal + '$(', // Command substitution + '`', // Backtick command substitution + '|', // Pipe + ';', // Command separator + '&', // Background/AND + '>', // Redirect + '<', // Redirect + "\n", // Newline + "\r", // Carriage return + "\0", // Null byte + "'", // Single quote + '"', // Double quote + '\\', // Backslash + ]; + + foreach ($dangerousPatterns as $pattern) { + if (str_contains($path, $pattern)) { + return false; + } + } + + // Allow alphanumerics, dots, dashes, underscores, slashes, and spaces + return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1; + } + + public bool $unsupported = false; + + // Store IDs instead of models for proper Livewire serialization + #[Locked] + public ?int $resourceId = null; + + #[Locked] + public ?string $resourceType = null; + + #[Locked] + public ?int $serverId = null; + + // View-friendly properties to avoid computed property access in Blade + #[Locked] + public string $resourceUuid = ''; + + public string $resourceStatus = ''; + + #[Locked] + public string $resourceDbType = ''; + + public array $parameters = []; + + public array $containers = []; + + public bool $scpInProgress = false; + + public bool $importRunning = false; + + public ?string $filename = null; + + public ?string $filesize = null; + + public bool $isUploading = false; + + public int $progress = 0; + + public bool $error = false; + + #[Locked] + public string $container; + + public array $importCommands = []; + + public bool $dumpAll = false; + + public string $restoreCommandText = ''; + + public string $customLocation = ''; + + public ?int $activityId = null; + + public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; + + public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; + + public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; + + public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='; + + // S3 Restore properties + public array $availableS3Storages = []; + + public ?int $s3StorageId = null; + + public string $s3Path = ''; + + public ?int $s3FileSize = null; + + #[Computed] + public function resource() + { + if ($this->resourceId === null || $this->resourceType === null) { + return null; + } + + return $this->resourceType::find($this->resourceId); + } + + #[Computed] + public function server() + { + if ($this->serverId === null) { + return null; + } + + return Server::ownedByCurrentTeam()->find($this->serverId); + } + + protected $listeners = [ + 'slideOverClosed' => 'resetActivityId', + ]; + + public function resetActivityId() + { + $this->activityId = null; + } + + public function mount() + { + $this->parameters = get_route_parameters(); + $this->getContainers(); + $this->loadAvailableS3Storages(); + } + + public function updatedDumpAll($value) + { + $morphClass = $this->resource->getMorphClass(); + + // Handle ServiceDatabase by checking the database type + if ($morphClass === ServiceDatabase::class) { + $dbType = $this->resource->databaseType(); + if (str_contains($dbType, 'mysql')) { + $morphClass = 'mysql'; + } elseif (str_contains($dbType, 'mariadb')) { + $morphClass = 'mariadb'; + } elseif (str_contains($dbType, 'postgres')) { + $morphClass = 'postgresql'; + } + } + + switch ($morphClass) { + case StandaloneMariadb::class: + case 'mariadb': + if ($value === true) { + $this->mariadbRestoreCommand = <<<'EOD' +for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do + mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true +done && \ +mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \ +mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \ +(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default} +EOD; + $this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}'; + } else { + $this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; + } + break; + case StandaloneMysql::class: + case 'mysql': + if ($value === true) { + $this->mysqlRestoreCommand = <<<'EOD' +for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do + mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true +done && \ +mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \ +mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \ +(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default} +EOD; + $this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}'; + } else { + $this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; + } + break; + case StandalonePostgresql::class: + case 'postgresql': + if ($value === true) { + $this->postgresqlRestoreCommand = <<<'EOD' +psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \ +psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \ +createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} +EOD; + $this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; + } else { + $this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; + } + break; + } + + } + + public function getContainers() + { + $this->containers = []; + $teamId = data_get(auth()->user()->currentTeam(), 'id'); + + // Try to find resource by route parameter + $databaseUuid = data_get($this->parameters, 'database_uuid'); + $stackServiceUuid = data_get($this->parameters, 'stack_service_uuid'); + + $resource = null; + if ($databaseUuid) { + // Standalone database route + $resource = getResourceByUuid($databaseUuid, $teamId); + if (is_null($resource)) { + abort(404); + } + } elseif ($stackServiceUuid) { + // ServiceDatabase route - look up the service database + $serviceUuid = data_get($this->parameters, 'service_uuid'); + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', data_get($this->parameters, 'project_uuid')) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') + ->where('uuid', data_get($this->parameters, 'environment_uuid')) + ->firstOrFail(); + $service = $environment->services()->whereUuid($serviceUuid)->firstOrFail(); + $resource = $service->databases()->whereUuid($stackServiceUuid)->first(); + if (is_null($resource)) { + abort(404); + } + } else { + abort(404); + } + + $this->authorize('view', $resource); + + // Store IDs for Livewire serialization + $this->resourceId = $resource->id; + $this->resourceType = get_class($resource); + + // Store view-friendly properties + $this->resourceStatus = $resource->status ?? ''; + + // Handle ServiceDatabase server access differently + if ($resource->getMorphClass() === ServiceDatabase::class) { + $server = $resource->service?->server; + if (! $server) { + abort(404, 'Server not found for this service database.'); + } + $this->serverId = $server->id; + $this->container = $resource->name.'-'.$resource->service->uuid; + $this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID + + // Determine database type for ServiceDatabase + $dbType = $resource->databaseType(); + if (str_contains($dbType, 'postgres')) { + $this->resourceDbType = 'standalone-postgresql'; + } elseif (str_contains($dbType, 'mysql')) { + $this->resourceDbType = 'standalone-mysql'; + } elseif (str_contains($dbType, 'mariadb')) { + $this->resourceDbType = 'standalone-mariadb'; + } elseif (str_contains($dbType, 'mongo')) { + $this->resourceDbType = 'standalone-mongodb'; + } else { + $this->resourceDbType = $dbType; + } + } else { + $server = $resource->destination?->server; + if (! $server) { + abort(404, 'Server not found for this database.'); + } + $this->serverId = $server->id; + $this->container = $resource->uuid; + $this->resourceUuid = $resource->uuid; + $this->resourceDbType = $resource->type(); + } + + if (str($resource->status)->startsWith('running')) { + $this->containers[] = $this->container; + } + + if ( + $resource->getMorphClass() === StandaloneRedis::class || + $resource->getMorphClass() === StandaloneKeydb::class || + $resource->getMorphClass() === StandaloneDragonfly::class || + $resource->getMorphClass() === StandaloneClickhouse::class + ) { + $this->unsupported = true; + } + + // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) + if ($resource->getMorphClass() === ServiceDatabase::class) { + $dbType = $resource->databaseType(); + if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') || + str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) { + $this->unsupported = true; + } + } + } + + public function checkFile() + { + if (filled($this->customLocation)) { + // Validate the custom location to prevent command injection + if (! $this->validateServerPath($this->customLocation)) { + $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); + + return; + } + + if (! $this->server) { + $this->dispatch('error', 'Server not found. Please refresh the page.'); + + return; + } + + try { + $escapedPath = escapeshellarg($this->customLocation); + $result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false); + if (blank($result)) { + $this->dispatch('error', 'The file does not exist or has been deleted.'); + + return; + } + $this->filename = $this->customLocation; + $this->dispatch('success', 'The file exists.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + } + + public function runImport(string $password = ''): bool|string + { + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + + $this->authorize('update', $this->resource); + + if (! ValidationPatterns::isValidContainerName($this->container)) { + $this->dispatch('error', 'Invalid container name.'); + + return true; + } + + if ($this->filename === '') { + $this->dispatch('error', 'Please select a file to import.'); + + return true; + } + + if (! $this->server) { + $this->dispatch('error', 'Server not found. Please refresh the page.'); + + return true; + } + + try { + $this->importRunning = true; + $this->importCommands = []; + $backupFileName = "upload/{$this->resourceUuid}/restore"; + + // Check if an uploaded file exists first (takes priority over custom location) + if (Storage::exists($backupFileName)) { + $path = Storage::path($backupFileName); + $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid; + instant_scp($path, $tmpPath, $this->server); + Storage::delete($backupFileName); + $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}"; + } elseif (filled($this->customLocation)) { + // Validate the custom location to prevent command injection + if (! $this->validateServerPath($this->customLocation)) { + $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.'); + + return true; + } + $tmpPath = '/tmp/restore_'.$this->resourceUuid; + $escapedCustomLocation = escapeshellarg($this->customLocation); + $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}"; + } else { + $this->dispatch('error', 'The file does not exist or has been deleted.'); + + return true; + } + + // Copy the restore command to a script file + $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; + + $restoreCommand = $this->buildRestoreCommand($tmpPath); + + $restoreCommandBase64 = base64_encode($restoreCommand); + $this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}"; + $this->importCommands[] = "chmod +x {$scriptPath}"; + $this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}"; + + $this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'"; + $this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; + + if (! empty($this->importCommands)) { + $activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [ + 'scriptPath' => $scriptPath, + 'tmpPath' => $tmpPath, + 'container' => $this->container, + 'serverId' => $this->server->id, + ]); + + // Track the activity ID + $this->activityId = $activity->id; + + // Dispatch activity to the monitor and open slide-over + $this->dispatch('activityMonitor', $activity->id); + $this->dispatch('databaserestore'); + } + } catch (\Throwable $e) { + handleError($e, $this); + + return true; + } finally { + $this->filename = null; + $this->importCommands = []; + } + + return true; + } + + public function loadAvailableS3Storages() + { + try { + $this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description']) + ->where('is_usable', true) + ->get() + ->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description]) + ->toArray(); + } catch (\Throwable $e) { + $this->availableS3Storages = []; + } + } + + public function updatedS3Path($value) + { + // Reset validation state when path changes + $this->s3FileSize = null; + + // Ensure path starts with a slash + if ($value !== null && $value !== '') { + $this->s3Path = str($value)->trim()->start('/')->value(); + } + } + + public function updatedS3StorageId() + { + // Reset validation state when storage changes + $this->s3FileSize = null; + } + + public function checkS3File() + { + if (! $this->s3StorageId) { + $this->dispatch('error', 'Please select an S3 storage.'); + + return; + } + + if (blank($this->s3Path)) { + $this->dispatch('error', 'Please provide an S3 path.'); + + return; + } + + // Clean the path (remove leading slash if present) + $cleanPath = ltrim($this->s3Path, '/'); + + // Validate the S3 path early to prevent command injection in subsequent operations + if (! $this->validateS3Path($cleanPath)) { + $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); + + return; + } + + try { + $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); + + // Validate bucket name early + if (! $this->validateBucketName($s3Storage->bucket)) { + $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); + + return; + } + + // Test connection + $s3Storage->testConnection(); + + // Build S3 disk configuration + $disk = Storage::build([ + 'driver' => 's3', + 'region' => $s3Storage->region, + 'key' => $s3Storage->key, + 'secret' => $s3Storage->secret, + 'bucket' => $s3Storage->bucket, + 'endpoint' => $s3Storage->endpoint, + 'use_path_style_endpoint' => true, + ]); + + // Check if file exists + if (! $disk->exists($cleanPath)) { + $this->dispatch('error', 'File not found in S3. Please check the path.'); + + return; + } + + // Get file size + $this->s3FileSize = $disk->size($cleanPath); + + $this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize)); + } catch (\Throwable $e) { + $this->s3FileSize = null; + + return handleError($e, $this); + } + } + + public function restoreFromS3(string $password = ''): bool|string + { + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + + $this->authorize('update', $this->resource); + + if (! ValidationPatterns::isValidContainerName($this->container)) { + $this->dispatch('error', 'Invalid container name.'); + + return true; + } + + if (! $this->s3StorageId || blank($this->s3Path)) { + $this->dispatch('error', 'Please select S3 storage and provide a path first.'); + + return true; + } + + if (is_null($this->s3FileSize)) { + $this->dispatch('error', 'Please check the file first by clicking "Check File".'); + + return true; + } + + if (! $this->server) { + $this->dispatch('error', 'Server not found. Please refresh the page.'); + + return true; + } + + try { + $this->importRunning = true; + + $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); + + $key = $s3Storage->key; + $secret = $s3Storage->secret; + $bucket = $s3Storage->bucket; + $endpoint = $s3Storage->endpoint; + + // Validate bucket name to prevent command injection + if (! $this->validateBucketName($bucket)) { + $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); + + return true; + } + + // Clean the S3 path + $cleanPath = ltrim($this->s3Path, '/'); + + // Validate the S3 path to prevent command injection + if (! $this->validateS3Path($cleanPath)) { + $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); + + return true; + } + + // Get helper image + $helperImage = config('constants.coolify.helper_image'); + $latestVersion = getHelperVersion(); + $fullImageName = "{$helperImage}:{$latestVersion}"; + + // Get the database destination network + if ($this->resource->getMorphClass() === ServiceDatabase::class) { + $destinationNetwork = $this->resource->service->destination->network ?? 'coolify'; + } else { + $destinationNetwork = $this->resource->destination->network ?? 'coolify'; + } + + // Generate unique names for this operation + $containerName = "s3-restore-{$this->resourceUuid}"; + $helperTmpPath = '/tmp/'.basename($cleanPath); + $serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath); + $containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath); + $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; + + // Prepare all commands in sequence + $commands = []; + + // 1. Clean up any existing helper container and temp files from previous runs + $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; + $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true"; + $commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true"; + + // 2. Start helper container on the database network + $commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600"; + + // 3. Configure S3 access in helper container + $escapedEndpoint = escapeshellarg($endpoint); + $escapedKey = escapeshellarg($key); + $escapedSecret = escapeshellarg($secret); + $commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; + + // 4. Check file exists in S3 (bucket and path already validated above) + $escapedBucket = escapeshellarg($bucket); + $escapedCleanPath = escapeshellarg($cleanPath); + $escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}"); + $commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}"; + + // 5. Download from S3 to helper container (progress shown by default) + $escapedHelperTmpPath = escapeshellarg($helperTmpPath); + $commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}"; + + // 6. Copy from helper to server, then immediately to database container + $commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}"; + $commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}"; + + // 7. Cleanup helper container and server temp file immediately (no longer needed) + $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; + $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true"; + + // 8. Build and execute restore command inside database container + $restoreCommand = $this->buildRestoreCommand($containerTmpPath); + + $restoreCommandBase64 = base64_encode($restoreCommand); + $commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}"; + $commands[] = "chmod +x {$scriptPath}"; + $commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}"; + + // 9. Execute restore and cleanup temp files immediately after completion + $commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'"; + $commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; + + // Execute all commands with cleanup event (as safety net for edge cases) + $activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [ + 'containerName' => $containerName, + 'serverTmpPath' => $serverTmpPath, + 'scriptPath' => $scriptPath, + 'containerTmpPath' => $containerTmpPath, + 'container' => $this->container, + 'serverId' => $this->server->id, + ]); + + // Track the activity ID + $this->activityId = $activity->id; + + // Dispatch activity to the monitor and open slide-over + $this->dispatch('activityMonitor', $activity->id); + $this->dispatch('databaserestore'); + $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); + } catch (\Throwable $e) { + $this->importRunning = false; + handleError($e, $this); + + return true; + } + + return true; + } + + public function buildRestoreCommand(string $tmpPath): string + { + $morphClass = $this->resource->getMorphClass(); + + // Handle ServiceDatabase by checking the database type + if ($morphClass === ServiceDatabase::class) { + $dbType = $this->resource->databaseType(); + if (str_contains($dbType, 'mysql')) { + $morphClass = 'mysql'; + } elseif (str_contains($dbType, 'mariadb')) { + $morphClass = 'mariadb'; + } elseif (str_contains($dbType, 'postgres')) { + $morphClass = 'postgresql'; + } elseif (str_contains($dbType, 'mongo')) { + $morphClass = 'mongodb'; + } + } + + switch ($morphClass) { + case StandaloneMariadb::class: + case 'mariadb': + $restoreCommand = $this->mariadbRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}"; + } else { + $restoreCommand .= " < {$tmpPath}"; + } + break; + case StandaloneMysql::class: + case 'mysql': + $restoreCommand = $this->mysqlRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}"; + } else { + $restoreCommand .= " < {$tmpPath}"; + } + break; + case StandalonePostgresql::class: + case 'postgresql': + $restoreCommand = $this->postgresqlRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}"; + } else { + $restoreCommand .= " {$tmpPath}"; + } + break; + case StandaloneMongodb::class: + case 'mongodb': + $restoreCommand = $this->mongodbRestoreCommand; + if ($this->dumpAll === false) { + $restoreCommand .= "{$tmpPath}"; + } + break; + default: + $restoreCommand = ''; + } + + return $restoreCommand; + } +} diff --git a/app/Livewire/Project/Service/ResourceCard.php b/app/Livewire/Project/Service/ResourceCard.php new file mode 100644 index 000000000..fd27f60c3 --- /dev/null +++ b/app/Livewire/Project/Service/ResourceCard.php @@ -0,0 +1,66 @@ +currentTeam(); + if (! $team) { + return []; + } + + return [ + "echo-private:team.{$team->id},ServiceChecked" => 'refreshResource', + ]; + } + + public function refreshResource(): void + { + $this->resource->refresh(); + } + + public function restart(): void + { + try { + $this->authorize('update', $this->service); + $this->resource->restart(); + $message = $this->resource instanceof ServiceApplication + ? 'Service application restarted successfully.' + : 'Service database restarted successfully.'; + $this->dispatch('success', $message); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function render(): View + { + return view('livewire.project.service.resource-card', [ + 'isApplication' => $this->resource instanceof ServiceApplication, + 'isDatabase' => $this->resource instanceof ServiceDatabase, + ]); + } +} diff --git a/resources/views/livewire/project/application/configuration.blade.php b/resources/views/livewire/project/application/configuration.blade.php index 848c46ff7..6986cef05 100644 --- a/resources/views/livewire/project/application/configuration.blade.php +++ b/resources/views/livewire/project/application/configuration.blade.php @@ -27,21 +27,7 @@ @endif Servers - @if ($application->server_status == false) - - - - - - @elseif ($application->additional_servers()->exists() && str($application->status)->contains('degraded')) - - - - - - @endif + str($currentRoute)->startsWith('project.application.scheduled-tasks')]) {{ wireNavigate() }} href="{{ route('project.application.scheduled-tasks.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Scheduled Tasks diff --git a/resources/views/livewire/project/application/server-status-badge.blade.php b/resources/views/livewire/project/application/server-status-badge.blade.php new file mode 100644 index 000000000..3413b3671 --- /dev/null +++ b/resources/views/livewire/project/application/server-status-badge.blade.php @@ -0,0 +1,17 @@ + + @if ($application->server_status == false) + + + + + + @elseif ($application->additional_servers()->exists() && str($application->status)->contains('degraded')) + + + + + + @endif + diff --git a/resources/views/livewire/project/database/import-form.blade.php b/resources/views/livewire/project/database/import-form.blade.php new file mode 100644 index 000000000..15306f9df --- /dev/null +++ b/resources/views/livewire/project/database/import-form.blade.php @@ -0,0 +1,228 @@ +
+ + @script + + @endscript +
+ + + + This is a destructive action, existing data will be replaced! +
+ {{-- Restore Command Configuration --}} + @if ($resourceDbType === 'standalone-postgresql') + @if ($dumpAll) + + @else + +
+ You can add "--clean" to drop objects before creating them, avoiding + conflicts. + You can add "--verbose" to log more things. +
+ @endif +
+ +
+ @elseif ($resourceDbType === 'standalone-mysql') + @if ($dumpAll) + + @else + + @endif +
+ +
+ @elseif ($resourceDbType === 'standalone-mariadb') + @if ($dumpAll) + + @else + + @endif +
+ +
+ @endif + + {{-- Restore Type Selection Boxes --}} +

Choose Restore Method

+
+
+
+ + + +

Restore from File

+

Upload a backup file or specify a file path on the server

+
+
+ + @if (count($availableS3Storages) > 0) +
+
+ + + +

Restore from S3

+

Download and restore a backup from S3 storage

+
+
+ @endif +
+ + {{-- File Restore Section --}} + @can('update', $this->resource) +
+

Backup File

+
+ + Check File +
+
+ Or +
+
+ @csrf +
+
+ +
+ +
+

File Information

+
Location:
+
+ + + Restore Database from File + + This will perform the following actions: +
    +
  • Copy backup file to database container
  • +
  • Execute restore command
  • +
+
WARNING: This will REPLACE all existing data!
+
+
+
+
+ @endcan + + {{-- S3 Restore Section --}} + @if (count($availableS3Storages) > 0) + @can('update', $this->resource) +
+

Restore from S3

+
+ + + @foreach ($availableS3Storages as $storage) + + @endforeach + + + + +
+ + Check File + +
+ + @if ($s3FileSize) +
+

File Information

+
Location: {{ $s3Path }} {{ formatBytes($s3FileSize ?? 0) }}
+
+ + + Restore Database from S3 + + This will perform the following actions: +
    +
  • Download backup from S3 storage
  • +
  • Copy file into database container
  • +
  • Execute restore command
  • +
+
WARNING: This will REPLACE all existing data!
+
+
+
+ @endif +
+
+ @endcan + @endif + + {{-- Slide-over for activity monitor (all restore operations) --}} + + Database Restore Output + +
+ +
+
+
+
\ No newline at end of file diff --git a/resources/views/livewire/project/database/import.blade.php b/resources/views/livewire/project/database/import.blade.php index 666abb3b3..75de25f71 100644 --- a/resources/views/livewire/project/database/import.blade.php +++ b/resources/views/livewire/project/database/import.blade.php @@ -1,237 +1,10 @@ -
- - @script - - @endscript +

Import Backup

@if ($unsupported)
Database restore is not supported.
+ @elseif (str($resourceStatus)->startsWith('running')) + @else -
- - - - This is a destructive action, existing data will be replaced! -
- @if (str($resourceStatus)->startsWith('running')) - {{-- Restore Command Configuration --}} - @if ($resourceDbType === 'standalone-postgresql') - @if ($dumpAll) - - @else - -
- You can add "--clean" to drop objects before creating them, avoiding - conflicts. - You can add "--verbose" to log more things. -
- @endif -
- -
- @elseif ($resourceDbType === 'standalone-mysql') - @if ($dumpAll) - - @else - - @endif -
- -
- @elseif ($resourceDbType === 'standalone-mariadb') - @if ($dumpAll) - - @else - - @endif -
- -
- @endif - - {{-- Restore Type Selection Boxes --}} -

Choose Restore Method

-
-
-
- - - -

Restore from File

-

Upload a backup file or specify a file path on the server

-
-
- - @if (count($availableS3Storages) > 0) -
-
- - - -

Restore from S3

-

Download and restore a backup from S3 storage

-
-
- @endif -
- - {{-- File Restore Section --}} - @can('update', $this->resource) -
-

Backup File

-
- - Check File -
-
- Or -
-
- @csrf -
-
- -
- -
-

File Information

-
Location:
-
- - - Restore Database from File - - This will perform the following actions: -
    -
  • Copy backup file to database container
  • -
  • Execute restore command
  • -
-
WARNING: This will REPLACE all existing data!
-
-
-
-
- @endcan - - {{-- S3 Restore Section --}} - @if (count($availableS3Storages) > 0) - @can('update', $this->resource) -
-

Restore from S3

-
- - - @foreach ($availableS3Storages as $storage) - - @endforeach - - - - -
- - Check File - -
- - @if ($s3FileSize) -
-

File Information

-
Location: {{ $s3Path }} {{ formatBytes($s3FileSize ?? 0) }}
-
- - - Restore Database from S3 - - This will perform the following actions: -
    -
  • Download backup from S3 storage
  • -
  • Copy file into database container
  • -
  • Execute restore command
  • -
-
WARNING: This will REPLACE all existing data!
-
-
-
- @endif -
-
- @endcan - @endif - - {{-- Slide-over for activity monitor (all restore operations) --}} - - Database Restore Output - -
- -
-
-
- @else -
Database must be running to restore a backup.
- @endif +
Database must be running to restore a backup.
@endif -
\ No newline at end of file +
diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php index ffe80b595..35b2ffd20 100644 --- a/resources/views/livewire/project/service/configuration.blade.php +++ b/resources/views/livewire/project/service/configuration.blade.php @@ -43,134 +43,12 @@ @endif @foreach ($applications as $application) -
str( - $application->status)->contains(['exited']), - 'border-l border-dashed border-success' => str( - $application->status)->contains(['running']), - 'border-l border-dashed border-warning' => str( - $application->status)->contains(['starting']), - 'flex gap-2 box-without-bg-without-border dark:bg-coolgray-100 bg-white dark:hover:text-neutral-300 group', - ])> -
-
-
- @if ($application->human_name) - {{ Str::headline($application->human_name) }} - @else - {{ Str::headline($application->name) }} - @endif - ({{ $application->image }}) -
- @if ($application->configuration_required) - (configuration required) - @endif - @if ($application->description) - {{ Str::limit($application->description, 60) }} - @endif - @if ($application->fqdn) - {{ Str::limit($application->fqdn, 60) }} - @can('update', $service) - - - - - - - - - - - - - - - @endcan - - @endif -
{{ formatContainerStatus($application->status) }}
-
-
- - Settings - - @if (str($application->status)->contains('running')) - @can('update', $service) - - @endcan - @endif -
-
-
+ @endforeach @foreach ($databases as $database) -
str($database->status)->contains( - ['exited']), - 'border-l border-dashed border-success' => str($database->status)->contains( - ['running']), - 'border-l border-dashed border-warning' => str($database->status)->contains( - ['restarting']), - 'flex gap-2 box-without-bg-without-border dark:bg-coolgray-100 bg-white dark:hover:text-neutral-300 group', - ])> -
-
-
- @if ($database->human_name) - {{ Str::headline($database->human_name) }} - @else - {{ Str::headline($database->name) }} - @endif - ({{ $database->image }}) -
- @if ($database->configuration_required) - (configuration required) - @endif - @if ($database->description) - {{ Str::limit($database->description, 60) }} - @endif -
{{ formatContainerStatus($database->status) }}
-
-
- @if ($database->isBackupSolutionAvailable() || $database->is_migrated) - - Backups - - @endif - - Settings - - @if (str($database->status)->contains('running')) - @can('update', $service) - - @endcan - @endif -
-
-
+ @endforeach
@elseif ($currentRoute === 'project.service.environment-variables') diff --git a/resources/views/livewire/project/service/resource-card.blade.php b/resources/views/livewire/project/service/resource-card.blade.php new file mode 100644 index 000000000..47fb00914 --- /dev/null +++ b/resources/views/livewire/project/service/resource-card.blade.php @@ -0,0 +1,77 @@ +
str($resource->status)->contains(['exited']), + 'border-l border-dashed border-success' => str($resource->status)->contains(['running']), + 'border-l border-dashed border-warning' => str($resource->status)->contains(['starting', 'restarting']), + 'flex gap-2 box-without-bg-without-border dark:bg-coolgray-100 bg-white dark:hover:text-neutral-300 group', +])> +
+
+
+ @if ($resource->human_name) + {{ Str::headline($resource->human_name) }} + @else + {{ Str::headline($resource->name) }} + @endif + ({{ $resource->image }}) +
+ @if ($resource->configuration_required) + (configuration required) + @endif + @if ($resource->description) + {{ Str::limit($resource->description, 60) }} + @endif + @if ($isApplication && $resource->fqdn) + {{ Str::limit($resource->fqdn, 60) }} + @can('update', $service) + + + + + + + + + + + + + + @endcan + + @endif +
$isApplication, 'text-xs'])>{{ formatContainerStatus($resource->status) }}
+
+
+ @if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated)) + + Backups + + @endif + + Settings + + @if (str($resource->status)->contains('running')) + @can('update', $service) + + @endcan + @endif +
+
+
diff --git a/tests/Feature/DatabaseSslStatusRefreshTest.php b/tests/Feature/DatabaseSslStatusRefreshTest.php index 7efb03789..6e8216eed 100644 --- a/tests/Feature/DatabaseSslStatusRefreshTest.php +++ b/tests/Feature/DatabaseSslStatusRefreshTest.php @@ -1,9 +1,13 @@ toHaveKey("echo-private:team.{$this->team->id},ServiceChecked"); })->with('database-status-info-components'); +it('keeps realtime status listeners on display-only components instead of form owners', function (string $componentClass) { + $listeners = resolveLivewireListeners(app($componentClass)); + + expect($listeners)->not->toBeEmpty(); +})->with('display-only-status-components'); + +it('refreshes a service resource card without refreshing the service configuration form owner', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $service = Service::create([ + 'name' => 'status-card-service', + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => 'services: {}', + ]); + $serviceApplication = ServiceApplication::create([ + 'service_id' => $service->id, + 'name' => 'web', + 'image' => 'nginx:latest', + 'status' => 'exited:unhealthy', + ]); + $parameters = [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'service_uuid' => $service->uuid, + ]; + + $component = Livewire::test(ServiceResourceCard::class, [ + 'service' => $service, + 'resource' => $serviceApplication, + 'parameters' => $parameters, + ]); + + $serviceApplication->fill(['status' => 'running:healthy'])->save(); + + $component->call('refreshResource'); + + expect($component->instance()->resource->status)->toBe('running:healthy'); +}); + +it('refreshes database import status from stored resource identity after the route context is gone', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $database = StandaloneMysql::create([ + 'name' => 'import-status-mysql', + 'image' => 'mysql:8', + 'mysql_root_password' => 'password', + 'mysql_user' => 'coolify', + 'mysql_password' => 'password', + 'mysql_database' => 'coolify', + 'status' => 'exited:unhealthy', + 'is_log_drain_enabled' => false, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $component = app(DatabaseImport::class); + $component->resourceId = $database->id; + $component->resourceType = StandaloneMysql::class; + + $database->fill(['status' => 'running:healthy'])->save(); + + $component->refreshStatus(); + + expect($component->resourceStatus)->toBe('running:healthy'); +}); + it('reloads the mysql status-info model when refresh is called so ssl controls follow the latest status', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $destination = StandaloneDocker::where('server_id', $server->id)->first(); From 902a60239d867be63e3c53c55a5e2ba0204d984e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 28 May 2026 20:46:38 +0200 Subject: [PATCH 33/90] fix(database): use named backup upload route --- resources/views/livewire/project/database/import-form.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/livewire/project/database/import-form.blade.php b/resources/views/livewire/project/database/import-form.blade.php index 15306f9df..ae74e0cbd 100644 --- a/resources/views/livewire/project/database/import-form.blade.php +++ b/resources/views/livewire/project/database/import-form.blade.php @@ -134,7 +134,7 @@ class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all"
Or
-
+ @csrf
From eb7da5c082342cc2b81e0bbbc705b7811becebc3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 28 May 2026 20:48:18 +0200 Subject: [PATCH 34/90] fix(database): gate import form controls by update access Require database import form controls to declare update authorization against the resource and add coverage to prevent unguarded controls. --- .../project/database/import-form.blade.php | 28 +++++++++---------- .../DatabaseImportFormAuthorizationTest.php | 20 +++++++++++++ 2 files changed, 34 insertions(+), 14 deletions(-) create mode 100644 tests/Feature/DatabaseImportFormAuthorizationTest.php diff --git a/resources/views/livewire/project/database/import-form.blade.php b/resources/views/livewire/project/database/import-form.blade.php index ae74e0cbd..1e384ac8d 100644 --- a/resources/views/livewire/project/database/import-form.blade.php +++ b/resources/views/livewire/project/database/import-form.blade.php @@ -58,9 +58,9 @@ @if ($resourceDbType === 'standalone-postgresql') @if ($dumpAll) + wire:model='restoreCommandText' canGate="update" :canResource="$this->resource"> @else - +
You can add "--clean" to drop objects before creating them, avoiding conflicts. @@ -68,27 +68,27 @@
@endif
- +
@elseif ($resourceDbType === 'standalone-mysql') @if ($dumpAll) + wire:model='restoreCommandText' canGate="update" :canResource="$this->resource"> @else - + @endif
- +
@elseif ($resourceDbType === 'standalone-mariadb') @if ($dumpAll) + wire:model='restoreCommandText' canGate="update" :canResource="$this->resource"> @else - + @endif
- +
@endif @@ -128,8 +128,8 @@ class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all"

Backup File

- Check File + wire:model='customLocation' x-model="$wire.customLocation" canGate="update" :canResource="$this->resource"> + Check File
Or @@ -168,7 +168,7 @@ class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all"

Restore from S3

- + @foreach ($availableS3Storages as $storage)