diff --git a/.github/workflows/chore-pr-comments.yml b/.github/workflows/chore-pr-comments.yml index f20729346..8836c6632 100644 --- a/.github/workflows/chore-pr-comments.yml +++ b/.github/workflows/chore-pr-comments.yml @@ -1,6 +1,6 @@ name: Add comment based on label on: - pull_request: + pull_request_target: types: - labeled jobs: @@ -8,6 +8,15 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write + contents: read + actions: none + checks: none + deployments: none + issues: none + packages: none + repository-projects: none + security-events: none + statuses: none strategy: matrix: include: diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index bc773072b..b0fc41448 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -6,7 +6,9 @@ on: pull_request_review_comment: types: [created] issues: - types: [opened, assigned] + types: [opened, assigned, labeled] + pull_request: + types: [labeled] pull_request_review: types: [submitted] @@ -16,12 +18,14 @@ jobs: (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + (github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'Claude') || + (github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'Claude') || + (github.event_name == 'issues' && github.event.action != 'labeled' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) runs-on: ubuntu-latest permissions: - contents: read - pull-requests: read - issues: read + contents: write + pull-requests: write + issues: write id-token: write actions: read # Required for Claude to read CI results on PRs steps: @@ -32,33 +36,27 @@ jobs: - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@beta + uses: anthropics/claude-code-action@v1 with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1) - # model: "claude-opus-4-1-20250805" - # Optional: Customize the trigger phrase (default: @claude) # trigger_phrase: "/claude" - + # Optional: Trigger when specific user is assigned to an issue # assignee_trigger: "claude-bot" - - # Optional: Allow Claude to run specific commands - # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" - - # Optional: Add custom instructions for Claude to customize its behavior for your project - # custom_instructions: | - # Follow our coding standards - # Ensure all new code has tests - # Use TypeScript for new files - - # Optional: Custom environment variables for Claude - # claude_env: | - # NODE_ENV: test + # Optional: Configure Claude's behavior with CLI arguments + # claude_args: | + # --model claude-opus-4-1-20250805 + # --max-turns 10 + # --allowedTools "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" + # --system-prompt "Follow our coding standards. Ensure all new code has tests. Use TypeScript for new files." + + # Optional: Advanced settings configuration + # settings: | + # { + # "env": { + # "NODE_ENV": "test" + # } + # } \ No newline at end of file diff --git a/README.md b/README.md index f291a33e8..1c88f4c54 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ ## Big Sponsors * [QuantCDN](https://www.quantcdn.io?ref=coolify.io) - Enterprise-grade content delivery network * [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang * [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers -* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital transformation and web solutions +* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency * [Cloudify.ro](https://cloudify.ro?ref=coolify.io) - Cloud hosting solutions * [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half * [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index e10422848..62fbe2df5 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -36,7 +36,6 @@ use Symfony\Component\Yaml\Yaml; use Throwable; use Visus\Cuid2\Cuid2; -use Yosymfony\Toml\Toml; class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { @@ -89,6 +88,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private bool $is_this_additional_server = false; + private bool $is_laravel_or_symfony = false; + private ?ApplicationPreview $preview = null; private ?string $git_type = null; @@ -772,6 +773,7 @@ private function deploy_nixpacks_buildpack() } } $this->clone_repository(); + $this->detect_laravel_symfony(); $this->cleanup_git(); $this->generate_nixpacks_confs(); $this->generate_compose_file(); @@ -1286,34 +1288,24 @@ private function elixir_finetunes() } } - private function laravel_finetunes() + private function symfony_finetunes(&$parsed) { - if ($this->pull_request_id === 0) { - $envType = 'environment_variables'; - } else { - $envType = 'environment_variables_preview'; - } - $nixpacks_php_fallback_path = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_FALLBACK_PATH')->first(); - $nixpacks_php_root_dir = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_ROOT_DIR')->first(); + $installCmds = data_get($parsed, 'phases.install.cmds', []); + $variables = data_get($parsed, 'variables', []); - if (! $nixpacks_php_fallback_path) { - $nixpacks_php_fallback_path = new EnvironmentVariable; - $nixpacks_php_fallback_path->key = 'NIXPACKS_PHP_FALLBACK_PATH'; - $nixpacks_php_fallback_path->value = '/index.php'; - $nixpacks_php_fallback_path->resourceable_id = $this->application->id; - $nixpacks_php_fallback_path->resourceable_type = 'App\Models\Application'; - $nixpacks_php_fallback_path->save(); - } - if (! $nixpacks_php_root_dir) { - $nixpacks_php_root_dir = new EnvironmentVariable; - $nixpacks_php_root_dir->key = 'NIXPACKS_PHP_ROOT_DIR'; - $nixpacks_php_root_dir->value = '/app/public'; - $nixpacks_php_root_dir->resourceable_id = $this->application->id; - $nixpacks_php_root_dir->resourceable_type = 'App\Models\Application'; - $nixpacks_php_root_dir->save(); + $envCommands = []; + foreach (array_keys($variables) as $key) { + $envCommands[] = "printf '%s=%s\\n' ".escapeshellarg($key)." \"\${$key}\" >> /app/.env"; } - return [$nixpacks_php_fallback_path, $nixpacks_php_root_dir]; + if (! empty($envCommands)) { + $createEnvCmd = 'touch /app/.env'; + + array_unshift($installCmds, $createEnvCmd); + array_splice($installCmds, 1, 0, $envCommands); + + data_set($parsed, 'phases.install.cmds', $installCmds); + } } private function rolling_update() @@ -1468,6 +1460,7 @@ private function deploy_pull_request() $this->prepare_builder_image(); $this->check_git_if_build_needed(); $this->clone_repository(); + $this->detect_laravel_symfony(); $this->cleanup_git(); if ($this->application->build_pack === 'nixpacks') { $this->generate_nixpacks_confs(); @@ -1734,6 +1727,78 @@ private function generate_git_import_commands() return $commands; } + private function detect_laravel_symfony() + { + if ($this->application->build_pack !== 'nixpacks') { + return; + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/composer.json && echo 'exists' || echo 'not-exists'"), + 'save' => 'composer_json_exists', + 'hidden' => true, + ]); + + if ($this->saved_outputs->get('composer_json_exists') == 'exists') { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, 'grep -E -q "laravel/framework|symfony/dotenv|symfony/framework-bundle|symfony/flex" '.$this->workdir.'/composer.json 2>/dev/null && echo "true" || echo "false"'), + 'save' => 'is_laravel_or_symfony', + 'hidden' => true, + ]); + + $this->is_laravel_or_symfony = $this->saved_outputs->get('is_laravel_or_symfony') == 'true'; + + if ($this->is_laravel_or_symfony) { + $this->application_deployment_queue->addLogEntry('Laravel/Symfony framework detected. Setting NIXPACKS PHP variables.'); + $this->ensure_nixpacks_php_variables(); + } + } + } + + private function ensure_nixpacks_php_variables() + { + if ($this->pull_request_id === 0) { + $envType = 'environment_variables'; + } else { + $envType = 'environment_variables_preview'; + } + + $nixpacks_php_fallback_path = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_FALLBACK_PATH')->first(); + $nixpacks_php_root_dir = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_ROOT_DIR')->first(); + + $created_new = false; + if (! $nixpacks_php_fallback_path) { + $nixpacks_php_fallback_path = new EnvironmentVariable; + $nixpacks_php_fallback_path->key = 'NIXPACKS_PHP_FALLBACK_PATH'; + $nixpacks_php_fallback_path->value = '/index.php'; + $nixpacks_php_fallback_path->is_buildtime = true; + $nixpacks_php_fallback_path->is_preview = $this->pull_request_id !== 0; + $nixpacks_php_fallback_path->resourceable_id = $this->application->id; + $nixpacks_php_fallback_path->resourceable_type = 'App\Models\Application'; + $nixpacks_php_fallback_path->save(); + $this->application_deployment_queue->addLogEntry('Created NIXPACKS_PHP_FALLBACK_PATH environment variable.'); + $created_new = true; + } + if (! $nixpacks_php_root_dir) { + $nixpacks_php_root_dir = new EnvironmentVariable; + $nixpacks_php_root_dir->key = 'NIXPACKS_PHP_ROOT_DIR'; + $nixpacks_php_root_dir->value = '/app/public'; + $nixpacks_php_root_dir->is_buildtime = true; + $nixpacks_php_root_dir->is_preview = $this->pull_request_id !== 0; + $nixpacks_php_root_dir->resourceable_id = $this->application->id; + $nixpacks_php_root_dir->resourceable_type = 'App\Models\Application'; + $nixpacks_php_root_dir->save(); + $this->application_deployment_queue->addLogEntry('Created NIXPACKS_PHP_ROOT_DIR environment variable.'); + $created_new = true; + } + + if ($this->pull_request_id === 0) { + $this->application->load(['nixpacks_environment_variables', 'environment_variables']); + } else { + $this->application->load(['nixpacks_environment_variables_preview', 'environment_variables_preview']); + } + } + private function cleanup_git() { $this->execute_remote_command( @@ -1743,30 +1808,51 @@ private function cleanup_git() private function generate_nixpacks_confs() { - $nixpacks_command = $this->nixpacks_build_cmd(); - $this->application_deployment_queue->addLogEntry("Generating nixpacks configuration with: $nixpacks_command"); - $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, $nixpacks_command), 'save' => 'nixpacks_plan', 'hidden' => true], [executeInDocker($this->deployment_uuid, "nixpacks detect {$this->workdir}"), 'save' => 'nixpacks_type', 'hidden' => true], ); + if ($this->saved_outputs->get('nixpacks_type')) { $this->nixpacks_type = $this->saved_outputs->get('nixpacks_type'); if (str($this->nixpacks_type)->isEmpty()) { throw new RuntimeException('Nixpacks failed to detect the application type. Please check the documentation of Nixpacks: https://nixpacks.com/docs/providers'); } } + $nixpacks_command = $this->nixpacks_build_cmd(); + $this->application_deployment_queue->addLogEntry("Generating nixpacks configuration with: $nixpacks_command"); + + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, $nixpacks_command), 'save' => 'nixpacks_plan', 'hidden' => true], + ); if ($this->saved_outputs->get('nixpacks_plan')) { $this->nixpacks_plan = $this->saved_outputs->get('nixpacks_plan'); if ($this->nixpacks_plan) { $this->application_deployment_queue->addLogEntry("Found application type: {$this->nixpacks_type}."); $this->application_deployment_queue->addLogEntry("If you need further customization, please check the documentation of Nixpacks: https://nixpacks.com/docs/providers/{$this->nixpacks_type}"); - $parsed = Toml::Parse($this->nixpacks_plan); + $parsed = json_decode($this->nixpacks_plan); // Do any modifications here // We need to generate envs here because nixpacks need to know to generate a proper Dockerfile $this->generate_env_variables(); + + if ($this->is_laravel_or_symfony) { + if ($this->pull_request_id === 0) { + $envType = 'environment_variables'; + } else { + $envType = 'environment_variables_preview'; + } + $nixpacks_php_fallback_path = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_FALLBACK_PATH')->first(); + $nixpacks_php_root_dir = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_ROOT_DIR')->first(); + + if ($nixpacks_php_fallback_path) { + data_set($parsed, 'variables.NIXPACKS_PHP_FALLBACK_PATH', $nixpacks_php_fallback_path->value); + } + if ($nixpacks_php_root_dir) { + data_set($parsed, 'variables.NIXPACKS_PHP_ROOT_DIR', $nixpacks_php_root_dir->value); + } + } + $merged_envs = collect(data_get($parsed, 'variables', []))->merge($this->env_args); $aptPkgs = data_get($parsed, 'phases.setup.aptPkgs', []); if (count($aptPkgs) === 0) { @@ -1782,23 +1868,23 @@ private function generate_nixpacks_confs() data_set($parsed, 'phases.setup.aptPkgs', $aptPkgs); } data_set($parsed, 'variables', $merged_envs->toArray()); - $is_laravel = data_get($parsed, 'variables.IS_LARAVEL', false); - if ($is_laravel) { - $variables = $this->laravel_finetunes(); - data_set($parsed, 'variables.NIXPACKS_PHP_FALLBACK_PATH', $variables[0]->value); - data_set($parsed, 'variables.NIXPACKS_PHP_ROOT_DIR', $variables[1]->value); + + if ($this->is_laravel_or_symfony) { + $this->symfony_finetunes($parsed); } + if ($this->nixpacks_type === 'elixir') { $this->elixir_finetunes(); } - $this->nixpacks_plan = json_encode($parsed, JSON_PRETTY_PRINT); - $this->nixpacks_plan_json = collect($parsed); - $this->application_deployment_queue->addLogEntry("Final Nixpacks plan: {$this->nixpacks_plan}", hidden: true); + if ($this->nixpacks_type === 'rust') { // temporary: disable healthcheck for rust because the start phase does not have curl/wget $this->application->health_check_enabled = false; $this->application->save(); } + $this->nixpacks_plan = json_encode($parsed, JSON_PRETTY_PRINT); + $this->nixpacks_plan_json = collect($parsed); + $this->application_deployment_queue->addLogEntry("Final Nixpacks plan: {$this->nixpacks_plan}", hidden: true); } } } @@ -1806,7 +1892,7 @@ private function generate_nixpacks_confs() private function nixpacks_build_cmd() { $this->generate_nixpacks_env_variables(); - $nixpacks_command = "nixpacks plan -f toml {$this->env_nixpacks_args}"; + $nixpacks_command = "nixpacks plan -f json {$this->env_nixpacks_args}"; if ($this->application->build_command) { $nixpacks_command .= " --build-cmd \"{$this->application->build_command}\""; } @@ -2944,7 +3030,6 @@ private function modify_dockerfile_for_secrets($dockerfile_path) $variables = $this->pull_request_id === 0 ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get() : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get(); - if ($variables->isEmpty()) { return; } diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 2ade83038..ae9bd314b 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -210,10 +210,10 @@ public function mount() } } $this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; - // Convert service names with dots to use underscores for HTML form binding + // Convert service names with dots and dashes to use underscores for HTML form binding $sanitizedDomains = []; foreach ($this->parsedServiceDomains as $serviceName => $domain) { - $sanitizedKey = str($serviceName)->slug('_')->toString(); + $sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString(); $sanitizedDomains[$sanitizedKey] = $domain; } $this->parsedServiceDomains = $sanitizedDomains; @@ -305,10 +305,10 @@ public function loadComposeFile($isInit = false, $showToast = true) // Refresh parsedServiceDomains to reflect any changes in docker_compose_domains $this->application->refresh(); $this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; - // Convert service names with dots to use underscores for HTML form binding + // Convert service names with dots and dashes to use underscores for HTML form binding $sanitizedDomains = []; foreach ($this->parsedServiceDomains as $serviceName => $domain) { - $sanitizedKey = str($serviceName)->slug('_')->toString(); + $sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString(); $sanitizedDomains[$sanitizedKey] = $domain; } $this->parsedServiceDomains = $sanitizedDomains; @@ -334,7 +334,7 @@ public function generateDomain(string $serviceName) $uuid = new Cuid2; $domain = generateUrl(server: $this->application->destination->server, random: $uuid); - $sanitizedKey = str($serviceName)->slug('_')->toString(); + $sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString(); $this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain; // Convert back to original service names for storage @@ -344,7 +344,7 @@ public function generateDomain(string $serviceName) $originalServiceName = $key; if (isset($this->parsedServices['services'])) { foreach ($this->parsedServices['services'] as $originalName => $service) { - if (str($originalName)->slug('_')->toString() === $key) { + if (str($originalName)->replace('-', '_')->replace('.', '_')->toString() === $key) { $originalServiceName = $originalName; break; } diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 5ff8f9137..77b106200 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -90,7 +90,7 @@ protected function rules() public function mount() { if (isDev()) { - $this->repository_url = 'https://github.com/coollabsio/coolify-examples'; + $this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/v4.x'; } $this->parameters = get_route_parameters(); $this->query = request()->query(); diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index f5978aea1..8ec818319 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -100,7 +100,7 @@ protected function rules() public function mount() { if (isDev()) { - $this->repository_url = 'https://github.com/coollabsio/coolify-examples'; + $this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/v4.x'; $this->port = 3000; } $this->parameters = get_route_parameters(); diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index ae94f7cf2..ee11c496d 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -47,6 +47,24 @@ public function submit() } } + public function toggleHealthcheck() + { + try { + $this->authorize('update', $this->resource); + $wasEnabled = $this->resource->health_check_enabled; + $this->resource->health_check_enabled = !$this->resource->health_check_enabled; + $this->resource->save(); + + if ($this->resource->health_check_enabled && !$wasEnabled && $this->resource->isRunning()) { + $this->dispatch('info', 'Health check has been enabled. A restart is required to apply the new settings.'); + } else { + $this->dispatch('success', 'Health check ' . ($this->resource->health_check_enabled ? 'enabled' : 'disabled') . '.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { return view('livewire.project.shared.health-checks'); diff --git a/app/Livewire/Server/Advanced.php b/app/Livewire/Server/Advanced.php index 760c4df0d..bbc3bd96a 100644 --- a/app/Livewire/Server/Advanced.php +++ b/app/Livewire/Server/Advanced.php @@ -27,9 +27,6 @@ class Advanced extends Component #[Validate(['integer', 'min:1'])] public int $dynamicTimeout = 1; - #[Validate(['boolean'])] - public bool $isTerminalEnabled = false; - public function mount(string $server_uuid) { try { @@ -42,36 +39,7 @@ public function mount(string $server_uuid) } } - public function toggleTerminal($password) - { - try { - // Check if user is admin or owner - if (! auth()->user()->isAdmin()) { - throw new \Exception('Only team administrators and owners can modify terminal access.'); - } - // Verify password unless two-step confirmation is disabled - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } - } - - // Toggle the terminal setting - $this->server->settings->is_terminal_enabled = ! $this->server->settings->is_terminal_enabled; - $this->server->settings->save(); - - // Update the local property - $this->isTerminalEnabled = $this->server->settings->is_terminal_enabled; - - $status = $this->isTerminalEnabled ? 'enabled' : 'disabled'; - $this->dispatch('success', "Terminal access has been {$status}."); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } public function syncData(bool $toModel = false) { @@ -88,7 +56,6 @@ public function syncData(bool $toModel = false) $this->dynamicTimeout = $this->server->settings->dynamic_timeout; $this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold; $this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency; - $this->isTerminalEnabled = $this->server->settings->is_terminal_enabled; } } diff --git a/app/Livewire/Server/Security/TerminalAccess.php b/app/Livewire/Server/Security/TerminalAccess.php new file mode 100644 index 000000000..284eea7dd --- /dev/null +++ b/app/Livewire/Server/Security/TerminalAccess.php @@ -0,0 +1,85 @@ +server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->authorize('update', $this->server); + $this->parameters = get_route_parameters(); + $this->syncData(); + + } catch (\Throwable) { + return redirect()->route('server.index'); + } + } + + public function toggleTerminal($password) + { + try { + $this->authorize('update', $this->server); + + // Check if user is admin or owner + if (! auth()->user()->isAdmin()) { + throw new \Exception('Only team administrators and owners can modify terminal access.'); + } + + // Verify password unless two-step confirmation is disabled + if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + } + + // Toggle the terminal setting + $this->server->settings->is_terminal_enabled = ! $this->server->settings->is_terminal_enabled; + $this->server->settings->save(); + + // Update the local property + $this->isTerminalEnabled = $this->server->settings->is_terminal_enabled; + + $status = $this->isTerminalEnabled ? 'enabled' : 'disabled'; + $this->dispatch('success', "Terminal access has been {$status}."); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function syncData(bool $toModel = false) + { + if ($toModel) { + $this->authorize('update', $this->server); + $this->validate(); + // No other fields to sync for terminal access + } else { + $this->isTerminalEnabled = $this->server->settings->is_terminal_enabled; + } + } + + public function render() + { + return view('livewire.server.security.terminal-access'); + } +} diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index c75474e44..bf0b7b6a5 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -146,7 +146,7 @@ public function validateDockerVersion() StartProxy::dispatch($this->server); } else { $requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.'); - $this->error = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not instaled. Please install Docker manually before continuing: documentation.'; + $this->error = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not installed. Please install Docker manually before continuing: documentation.'; $this->server->update([ 'validation_logs' => $this->error, ]); diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index 0bac39db8..45f7e467f 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -48,6 +48,8 @@ private function generateInviteLink(bool $sendEmail = false) if (auth()->user()->role() === 'admin' && $this->role === 'owner') { throw new \Exception('Admins cannot invite owners.'); } + $this->email = strtolower($this->email); + $member_emails = currentTeam()->members()->get()->pluck('email'); if ($member_emails->contains($this->email)) { return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.'); diff --git a/app/Models/Application.php b/app/Models/Application.php index 094e5c82b..9fffdfcda 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1477,16 +1477,17 @@ public function loadComposeFile($isInit = false) $this->save(); $parsedServices = $this->parse(); if ($this->docker_compose_domains) { - $json = collect(json_decode($this->docker_compose_domains)); + $decoded = json_decode($this->docker_compose_domains, true); + $json = collect(is_array($decoded) ? $decoded : []); + $normalized = collect(); foreach ($json as $key => $value) { - if (str($key)->contains('-')) { - $key = str($key)->replace('-', '_')->replace('.', '_'); - } - $json->put((string) $key, $value); + $normalizedKey = (string) str($key)->replace('-', '_')->replace('.', '_'); + $normalized->put($normalizedKey, $value); } + $json = $normalized; $services = collect(data_get($parsedServices, 'services', [])); foreach ($services as $name => $service) { - if (str($name)->contains('-')) { + if (str($name)->contains('-') || str($name)->contains('.')) { $replacedName = str($name)->replace('-', '_')->replace('.', '_'); $services->put((string) $replacedName, $service); $services->forget((string) $name); @@ -1555,40 +1556,206 @@ protected function buildGitCheckoutCommand($target): string return $command; } + private function parseWatchPaths($value) + { + if ($value) { + $watch_paths = collect(explode("\n", $value)) + ->map(function (string $path): string { + // Trim whitespace + $path = trim($path); + + if (str_starts_with($path, '!')) { + $negation = '!'; + $pathWithoutNegation = substr($path, 1); + $pathWithoutNegation = ltrim(trim($pathWithoutNegation), '/'); + + return $negation.$pathWithoutNegation; + } + + return ltrim($path, '/'); + }) + ->filter(function (string $path): bool { + return strlen($path) > 0; + }); + + return trim($watch_paths->implode("\n")); + } + } + public function watchPaths(): Attribute { return Attribute::make( set: function ($value) { if ($value) { - return trim($value); + return $this->parseWatchPaths($value); } } ); } + public function matchWatchPaths(Collection $modified_files, ?Collection $watch_paths): Collection + { + return self::matchPaths($modified_files, $watch_paths); + } + + /** + * Static method to match paths against watch patterns with negation support + * Uses order-based matching: last matching pattern wins + */ + public static function matchPaths(Collection $modified_files, ?Collection $watch_paths): Collection + { + if (is_null($watch_paths) || $watch_paths->isEmpty()) { + return collect([]); + } + + return $modified_files->filter(function ($file) use ($watch_paths) { + $shouldInclude = null; // null means no patterns matched + + // Process patterns in order - last match wins + foreach ($watch_paths as $pattern) { + $pattern = trim($pattern); + if (empty($pattern)) { + continue; + } + + $isExclusion = str_starts_with($pattern, '!'); + $matchPattern = $isExclusion ? substr($pattern, 1) : $pattern; + + if (self::globMatch($matchPattern, $file)) { + // This pattern matches - it determines the current state + $shouldInclude = ! $isExclusion; + } + } + + // If no patterns matched and we only have exclusion patterns, include by default + if ($shouldInclude === null) { + // Check if we only have exclusion patterns + $hasInclusionPatterns = $watch_paths->contains(fn ($p) => ! str_starts_with(trim($p), '!')); + + return ! $hasInclusionPatterns; + } + + return $shouldInclude; + })->values(); + } + + /** + * Check if a path matches a glob pattern + * Supports: *, **, ?, [abc], [!abc] + */ + public static function globMatch(string $pattern, string $path): bool + { + $regex = self::globToRegex($pattern); + + return preg_match($regex, $path) === 1; + } + + /** + * Convert a glob pattern to a regular expression + */ + public static function globToRegex(string $pattern): string + { + $regex = ''; + $inGroup = false; + $chars = str_split($pattern); + $len = count($chars); + + for ($i = 0; $i < $len; $i++) { + $c = $chars[$i]; + + switch ($c) { + case '*': + // Check for ** + if ($i + 1 < $len && $chars[$i + 1] === '*') { + // ** matches any number of directories + $regex .= '.*'; + $i++; // Skip next * + // Skip optional / + if ($i + 1 < $len && $chars[$i + 1] === '/') { + $i++; + } + } else { + // * matches anything except / + $regex .= '[^/]*'; + } + break; + + case '?': + // ? matches any single character except / + $regex .= '[^/]'; + break; + + case '[': + // Character class + $inGroup = true; + $regex .= '['; + // Check for negation + if ($i + 1 < $len && ($chars[$i + 1] === '!' || $chars[$i + 1] === '^')) { + $regex .= '^'; + $i++; + } + break; + + case ']': + if ($inGroup) { + $inGroup = false; + $regex .= ']'; + } else { + $regex .= preg_quote($c, '#'); + } + break; + + case '.': + case '(': + case ')': + case '+': + case '{': + case '}': + case '$': + case '^': + case '|': + case '\\': + // Escape regex special characters + $regex .= '\\'.$c; + break; + + default: + $regex .= $c; + break; + } + } + + // Wrap in delimiters and anchors + return '#^'.$regex.'$#'; + } + + public function normalizeWatchPaths(): void + { + if (is_null($this->watch_paths)) { + return; + } + + $normalized = $this->parseWatchPaths($this->watch_paths); + if ($normalized !== $this->watch_paths) { + $this->watch_paths = $normalized; + $this->save(); + } + } + public function isWatchPathsTriggered(Collection $modified_files): bool { if (is_null($this->watch_paths)) { return false; } - $watch_paths = collect(explode("\n", $this->watch_paths)) - ->map(function (string $path): string { - return trim($path); - }) - ->filter(function (string $path): bool { - return strlen($path) > 0; - }); - // If no valid patterns after filtering, don't trigger + $this->normalizeWatchPaths(); + + $watch_paths = collect(explode("\n", $this->watch_paths)); + if ($watch_paths->isEmpty()) { return false; } - - $matches = $modified_files->filter(function ($file) use ($watch_paths) { - return $watch_paths->contains(function ($glob) use ($file) { - return fnmatch($glob, $file); - }); - }); + $matches = $this->matchWatchPaths($modified_files, $watch_paths); return $matches->count() > 0; } diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index 4656457ae..3ade21df8 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -12,12 +12,12 @@ class ScheduledDatabaseBackup extends BaseModel public static function ownedByCurrentTeam() { - return ScheduledDatabaseBackup::whereRelation('team', 'id', currentTeam()->id)->orderBy('name'); + return ScheduledDatabaseBackup::whereRelation('team', 'id', currentTeam()->id)->orderBy('created_at', 'desc'); } public static function ownedByCurrentTeamAPI(int $teamId) { - return ScheduledDatabaseBackup::whereRelation('team', 'id', $teamId)->orderBy('name'); + return ScheduledDatabaseBackup::whereRelation('team', 'id', $teamId)->orderBy('created_at', 'desc'); } public function team() diff --git a/app/Models/Team.php b/app/Models/Team.php index 97a7d89d7..51fdeffa4 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -10,6 +10,7 @@ use App\Traits\HasNotificationSettings; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Notifications\Notifiable; use OpenApi\Attributes as OA; @@ -37,7 +38,7 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, SendsSlack { - use HasNotificationSettings, HasSafeStringAttribute, Notifiable; + use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; protected $guarded = []; diff --git a/app/Models/TeamInvitation.php b/app/Models/TeamInvitation.php index 0fea1806b..c322982ed 100644 --- a/app/Models/TeamInvitation.php +++ b/app/Models/TeamInvitation.php @@ -15,6 +15,14 @@ class TeamInvitation extends Model 'via', ]; + /** + * Set the email attribute to lowercase. + */ + public function setEmailAttribute(string $value): void + { + $this->attributes['email'] = strtolower($value); + } + public function team() { return $this->belongsTo(Team::class); diff --git a/app/Rules/ValidGitRepositoryUrl.php b/app/Rules/ValidGitRepositoryUrl.php index d549961dc..ba1aed11b 100644 --- a/app/Rules/ValidGitRepositoryUrl.php +++ b/app/Rules/ValidGitRepositoryUrl.php @@ -136,14 +136,14 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // Validate path contains only safe characters $path = $parsed['path'] ?? ''; - if (! empty($path) && ! preg_match('/^[a-zA-Z0-9\-_\/\.]+$/', $path)) { + if (! empty($path) && ! preg_match('/^[a-zA-Z0-9\-_\/\.@~]+$/', $path)) { $fail('The :attribute path contains invalid characters.'); return; } } elseif (str_starts_with($value, 'git://')) { - // Validate git:// protocol URL - if (! preg_match('/^git:\/\/[a-zA-Z0-9\.\-]+\/[a-zA-Z0-9\-_\/\.]+$/', $value)) { + // Validate git:// protocol URL (supports both git://host/path and git://host:port/path with tilde) + if (! preg_match('/^git:\/\/[a-zA-Z0-9\.\-]+(:[0-9]+)?[:\/][a-zA-Z0-9\-_\/\.~]+$/', $value)) { $fail('The :attribute is not a valid git:// URL.'); return; diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index f9df19c16..8fa47f543 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -155,7 +155,7 @@ public function execute_remote_command(...$commands) private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors) { $remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command); - $process = Process::timeout(3600)->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) { + $process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) { $output = str($output)->trim(); if ($output->startsWith('╔')) { $output = "\n".$output; @@ -202,13 +202,13 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe if ($this->save) { if (data_get($this->saved_outputs, $this->save, null) === null) { - data_set($this->saved_outputs, $this->save, str()); + $this->saved_outputs->put($this->save, str()); } if ($append) { - $this->saved_outputs[$this->save] .= str($sanitized_output)->trim(); - $this->saved_outputs[$this->save] = str($this->saved_outputs[$this->save]); + $current_value = $this->saved_outputs->get($this->save); + $this->saved_outputs->put($this->save, str($current_value.str($sanitized_output)->trim())); } else { - $this->saved_outputs[$this->save] = str($sanitized_output)->trim(); + $this->saved_outputs->put($this->save, str($sanitized_output)->trim()); } } }); @@ -269,4 +269,4 @@ private function addRetryLogEntry(int $attempt, int $maxRetries, int $delay, str $this->application_deployment_queue->save(); } -} +} \ No newline at end of file diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index d4701d251..25cc5d0a6 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -385,21 +385,34 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'is_preview' => false, ]); if ($resource->build_pack === 'dockercompose') { - $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]); - $domainExists = data_get($domains->get($fqdnFor), 'domain'); - $envExists = $resource->environment_variables()->where('key', $key->value())->first(); - if (str($domainExists)->replace('http://', '')->replace('https://', '')->value() !== $envExists->value) { - $envExists->update([ - 'value' => $url, - ]); + // Check if a service with this name actually exists + $serviceExists = false; + foreach ($services as $serviceName => $service) { + $transformedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); + if ($transformedServiceName === $fqdnFor) { + $serviceExists = true; + break; + } } - if (is_null($domainExists)) { - // Put URL in the domains array instead of FQDN - $domains->put((string) $fqdnFor, [ - 'domain' => $url, - ]); - $resource->docker_compose_domains = $domains->toJson(); - $resource->save(); + + // Only add domain if the service exists + if ($serviceExists) { + $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]); + $domainExists = data_get($domains->get($fqdnFor), 'domain'); + $envExists = $resource->environment_variables()->where('key', $key->value())->first(); + if (str($domainExists)->replace('http://', '')->replace('https://', '')->value() !== $envExists->value) { + $envExists->update([ + 'value' => $url, + ]); + } + if (is_null($domainExists)) { + // Put URL in the domains array instead of FQDN + $domains->put((string) $fqdnFor, [ + 'domain' => $url, + ]); + $resource->docker_compose_domains = $domains->toJson(); + $resource->save(); + } } } } elseif ($command->value() === 'URL') { @@ -418,20 +431,33 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'is_preview' => false, ]); if ($resource->build_pack === 'dockercompose') { - $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]); - $domainExists = data_get($domains->get($urlFor), 'domain'); - $envExists = $resource->environment_variables()->where('key', $key->value())->first(); - if ($domainExists !== $envExists->value) { - $envExists->update([ - 'value' => $url, - ]); + // Check if a service with this name actually exists + $serviceExists = false; + foreach ($services as $serviceName => $service) { + $transformedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); + if ($transformedServiceName === $urlFor) { + $serviceExists = true; + break; + } } - if (is_null($domainExists)) { - $domains->put((string) $urlFor, [ - 'domain' => $url, - ]); - $resource->docker_compose_domains = $domains->toJson(); - $resource->save(); + + // Only add domain if the service exists + if ($serviceExists) { + $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]); + $domainExists = data_get($domains->get($urlFor), 'domain'); + $envExists = $resource->environment_variables()->where('key', $key->value())->first(); + if ($domainExists !== $envExists->value) { + $envExists->update([ + 'value' => $url, + ]); + } + if (is_null($domainExists)) { + $domains->put((string) $urlFor, [ + 'domain' => $url, + ]); + $resource->docker_compose_domains = $domains->toJson(); + $resource->save(); + } } } } else { @@ -910,7 +936,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview = $resource->previews()->find($preview_id); $docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))); if ($docker_compose_domains->count() > 0) { - $found_fqdn = data_get($docker_compose_domains, "$serviceName.domain"); + $found_fqdn = data_get($docker_compose_domains, "$changedServiceName.domain"); if ($found_fqdn) { $fqdns = collect($found_fqdn); } else { diff --git a/bootstrap/helpers/socialite.php b/bootstrap/helpers/socialite.php index 961f6809b..3b20f2d89 100644 --- a/bootstrap/helpers/socialite.php +++ b/bootstrap/helpers/socialite.php @@ -70,8 +70,14 @@ function get_socialite_provider(string $provider) 'infomaniak' => \SocialiteProviders\Infomaniak\Provider::class, ]; - return Socialite::buildProvider( + $socialite = Socialite::buildProvider( $provider_class_map[$provider], $config ); + + if ($provider == 'gitlab' && !empty($oauth_setting->base_url)) { + $socialite->setHost($oauth_setting->base_url); + } + + return $socialite; } diff --git a/config/constants.php b/config/constants.php index bdf21588c..ea73d426a 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.431', + 'version' => '4.0.0-beta.432', 'helper_version' => '1.0.11', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), @@ -64,7 +64,7 @@ 'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes 'connection_timeout' => 10, 'server_interval' => 20, - 'command_timeout' => 7200, + 'command_timeout' => 3600, 'max_retries' => env('SSH_MAX_RETRIES', 3), 'retry_base_delay' => env('SSH_RETRY_BASE_DELAY', 2), // seconds 'retry_max_delay' => env('SSH_RETRY_MAX_DELAY', 30), // seconds diff --git a/database/factories/TeamFactory.php b/database/factories/TeamFactory.php new file mode 100644 index 000000000..0e95842b4 --- /dev/null +++ b/database/factories/TeamFactory.php @@ -0,0 +1,40 @@ + + */ +class TeamFactory extends Factory +{ + protected $model = Team::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => $this->faker->company() . ' Team', + 'description' => $this->faker->sentence(), + 'personal_team' => false, + 'show_boarding' => false, + ]; + } + + /** + * Indicate that the team is a personal team. + */ + public function personal(): static + { + return $this->state(fn (array $attributes) => [ + 'personal_team' => true, + 'name' => $this->faker->firstName() . "'s Team", + ]); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e0e7a3ba5..57ccab4ae 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -29,6 +29,7 @@ public function run(): void DisableTwoStepConfirmationSeeder::class, SentinelSeeder::class, CaSslCertSeeder::class, + PersonalAccessTokenSeeder::class, ]); } } diff --git a/database/seeders/PersonalAccessTokenSeeder.php b/database/seeders/PersonalAccessTokenSeeder.php new file mode 100644 index 000000000..38a45219c --- /dev/null +++ b/database/seeders/PersonalAccessTokenSeeder.php @@ -0,0 +1,115 @@ +environment('production')) { + $this->command->warn('Skipping PersonalAccessTokenSeeder in production environment'); + + return; + } + + // Get the first user (usually the admin user created during setup) + $user = User::find(0); + + if (! $user) { + $this->command->warn('No user found. Please run UserSeeder first.'); + + return; + } + + // Get the user's first team + $team = $user->teams()->first(); + + if (! $team) { + $this->command->warn('No team found for user. Cannot create API tokens.'); + + return; + } + + // Define test tokens with different scopes + $testTokens = [ + [ + 'name' => 'Development Root Token', + 'token' => 'root', + 'abilities' => ['root'], + ], + [ + 'name' => 'Development Read Token', + 'token' => 'read', + 'abilities' => ['read'], + ], + [ + 'name' => 'Development Read Sensitive Token', + 'token' => 'read-sensitive', + 'abilities' => ['read', 'read:sensitive'], + ], + [ + 'name' => 'Development Write Token', + 'token' => 'write', + 'abilities' => ['write'], + ], + [ + 'name' => 'Development Write Sensitive Token', + 'token' => 'write-sensitive', + 'abilities' => ['write', 'write:sensitive'], + ], + [ + 'name' => 'Development Deploy Token', + 'token' => 'deploy', + 'abilities' => ['deploy'], + ], + ]; + + // First, remove all existing development tokens for this user + $deletedCount = PersonalAccessToken::where('tokenable_id', $user->id) + ->where('tokenable_type', get_class($user)) + ->whereIn('name', array_column($testTokens, 'name')) + ->delete(); + + if ($deletedCount > 0) { + $this->command->info("Removed {$deletedCount} existing development token(s)."); + } + + // Now create fresh tokens + foreach ($testTokens as $tokenData) { + // Create the token with a simple format: Bearer {scope} + // The token format in the database is the hash of the plain text token + $plainTextToken = $tokenData['token']; + + PersonalAccessToken::create([ + 'tokenable_type' => get_class($user), + 'tokenable_id' => $user->id, + 'name' => $tokenData['name'], + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $tokenData['abilities'], + 'team_id' => $team->id, + ]); + + $this->command->info("Created token '{$tokenData['name']}' with Bearer token: {$plainTextToken}"); + } + + $this->command->info(''); + $this->command->info('Test API tokens created successfully!'); + $this->command->info('You can use these tokens in development as:'); + $this->command->info(' Bearer root - Root access'); + $this->command->info(' Bearer read - Read only access'); + $this->command->info(' Bearer read-sensitive - Read with sensitive data access'); + $this->command->info(' Bearer write - Write access'); + $this->command->info(' Bearer write-sensitive - Write with sensitive data access'); + $this->command->info(' Bearer deploy - Deploy access'); + } +} diff --git a/other/nightly/install.sh b/other/nightly/install.sh index 92ad12302..bcd37e71f 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -20,7 +20,6 @@ DATE=$(date +"%Y%m%d-%H%M%S") OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"') ENV_FILE="/data/coolify/source/.env" -VERSION="21" DOCKER_VERSION="27.0" # TODO: Ask for a user CURRENT_USER=$USER @@ -32,7 +31,7 @@ fi echo -e "Welcome to Coolify Installer!" echo -e "This script will install everything for you. Sit back and relax." -echo -e "Source code: https://github.com/coollabsio/coolify/blob/main/scripts/install.sh\n" +echo -e "Source code: https://github.com/coollabsio/coolify/blob/v4.x/scripts/install.sh" # Predefined root user ROOT_USERNAME=${ROOT_USERNAME:-} @@ -711,84 +710,80 @@ curl -fsSL $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.p curl -fsSL $CDN/.env.production -o /data/coolify/source/.env.production curl -fsSL $CDN/upgrade.sh -o /data/coolify/source/upgrade.sh -echo -e "6. Make backup of .env to .env-$DATE" +echo -e "6. Setting up environment variable file" -# Copy .env.example if .env does not exist -if [ -f $ENV_FILE ]; then - cp $ENV_FILE $ENV_FILE-$DATE +if [ -f "$ENV_FILE" ]; then + # If .env exists, create backup + echo " - Creating backup of existing .env file to .env-$DATE" + cp "$ENV_FILE" "$ENV_FILE-$DATE" + # Merge .env.production values into .env + echo " - Merging .env.production values into .env" + awk -F '=' '!seen[$1]++' "$ENV_FILE" "/data/coolify/source/.env.production" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" + echo " - .env file merged successfully" else - echo " - File does not exist: $ENV_FILE" - echo " - Copying .env.production to .env-$DATE" - cp /data/coolify/source/.env.production $ENV_FILE-$DATE - # Generate a secure APP_ID and APP_KEY - sed -i "s|^APP_ID=.*|APP_ID=$(openssl rand -hex 16)|" "$ENV_FILE-$DATE" - sed -i "s|^APP_KEY=.*|APP_KEY=base64:$(openssl rand -base64 32)|" "$ENV_FILE-$DATE" - - # Generate a secure Postgres DB username and password - # Causes issues: database "random-user" does not exist - # sed -i "s|^DB_USERNAME=.*|DB_USERNAME=$(openssl rand -hex 16)|" "$ENV_FILE-$DATE" - sed -i "s|^DB_PASSWORD=.*|DB_PASSWORD=$(openssl rand -base64 32)|" "$ENV_FILE-$DATE" - - # Generate a secure Redis password - sed -i "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=$(openssl rand -base64 32)|" "$ENV_FILE-$DATE" - - # Generate secure Pusher credentials - sed -i "s|^PUSHER_APP_ID=.*|PUSHER_APP_ID=$(openssl rand -hex 32)|" "$ENV_FILE-$DATE" - sed -i "s|^PUSHER_APP_KEY=.*|PUSHER_APP_KEY=$(openssl rand -hex 32)|" "$ENV_FILE-$DATE" - sed -i "s|^PUSHER_APP_SECRET=.*|PUSHER_APP_SECRET=$(openssl rand -hex 32)|" "$ENV_FILE-$DATE" + # If no .env exists, copy .env.production to .env + echo " - No .env file found, copying .env.production to .env" + cp "/data/coolify/source/.env.production" "$ENV_FILE" fi +echo -e "7. Checking and updating environment variables if necessary..." + +update_env_var() { + local key="$1" + local value="$2" + + # If variable "key=" exists but has no value, update the value of the existing line + if grep -q "^${key}=$" "$ENV_FILE"; then + sed -i "s|^${key}=$|${key}=${value}|" "$ENV_FILE" + echo " - Updated value of ${key} as the current value was empty" + # If variable "key=" doesn't exist, append it to the file with value + elif ! grep -q "^${key}=" "$ENV_FILE"; then + printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE" + echo " - Added ${key} and it's value as the variable was missing" + fi +} + +update_env_var "APP_ID" "$(openssl rand -hex 16)" +update_env_var "APP_KEY" "base64:$(openssl rand -base64 32)" +# update_env_var "DB_USERNAME" "$(openssl rand -hex 16)" # Causes issues: database "random-user" does not exist +update_env_var "DB_PASSWORD" "$(openssl rand -base64 32)" +update_env_var "REDIS_PASSWORD" "$(openssl rand -base64 32)" +update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)" +update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)" +update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)" + # Add default root user credentials from environment variables if [ -n "$ROOT_USERNAME" ] && [ -n "$ROOT_USER_EMAIL" ] && [ -n "$ROOT_USER_PASSWORD" ]; then - if grep -q "^ROOT_USERNAME=" "$ENV_FILE-$DATE"; then - sed -i "s|^ROOT_USERNAME=.*|ROOT_USERNAME=$ROOT_USERNAME|" "$ENV_FILE-$DATE" - fi - if grep -q "^ROOT_USER_EMAIL=" "$ENV_FILE-$DATE"; then - sed -i "s|^ROOT_USER_EMAIL=.*|ROOT_USER_EMAIL=$ROOT_USER_EMAIL|" "$ENV_FILE-$DATE" - fi - if grep -q "^ROOT_USER_PASSWORD=" "$ENV_FILE-$DATE"; then - sed -i "s|^ROOT_USER_PASSWORD=.*|ROOT_USER_PASSWORD=$ROOT_USER_PASSWORD|" "$ENV_FILE-$DATE" - fi + echo " - Setting predefined root user credentials from environment" + update_env_var "ROOT_USERNAME" "$ROOT_USERNAME" + update_env_var "ROOT_USER_EMAIL" "$ROOT_USER_EMAIL" + update_env_var "ROOT_USER_PASSWORD" "$ROOT_USER_PASSWORD" fi -# Add registry URL to .env file if [ -n "${REGISTRY_URL+x}" ]; then # Only update if REGISTRY_URL was explicitly provided - if grep -q "^REGISTRY_URL=" "$ENV_FILE-$DATE"; then - sed -i "s|^REGISTRY_URL=.*|REGISTRY_URL=$REGISTRY_URL|" "$ENV_FILE-$DATE" - else - echo "REGISTRY_URL=$REGISTRY_URL" >>"$ENV_FILE-$DATE" - fi + update_env_var "REGISTRY_URL" "$REGISTRY_URL" fi -# Merge .env and .env.production. New values will be added to .env -echo -e "7. Propagating .env with new values - if necessary." -awk -F '=' '!seen[$1]++' "$ENV_FILE-$DATE" /data/coolify/source/.env.production >$ENV_FILE - if [ "$AUTOUPDATE" = "false" ]; then - if ! grep -q "AUTOUPDATE=" /data/coolify/source/.env; then - echo "AUTOUPDATE=false" >>/data/coolify/source/.env - else - sed -i "s|AUTOUPDATE=.*|AUTOUPDATE=false|g" /data/coolify/source/.env + update_env_var "AUTOUPDATE" "false" +fi + +if [ "$DOCKER_POOL_BASE_PROVIDED" = true ]; then + update_env_var "DOCKER_ADDRESS_POOL_BASE" "$DOCKER_ADDRESS_POOL_BASE" +else + # Add with default value if missing + if ! grep -q "^DOCKER_ADDRESS_POOL_BASE=" "$ENV_FILE"; then + update_env_var "DOCKER_ADDRESS_POOL_BASE" "$DOCKER_ADDRESS_POOL_BASE" fi fi -# Save Docker address pool configuration to .env file -if ! grep -q "DOCKER_ADDRESS_POOL_BASE=" /data/coolify/source/.env; then - echo "DOCKER_ADDRESS_POOL_BASE=$DOCKER_ADDRESS_POOL_BASE" >>/data/coolify/source/.env +if [ "$DOCKER_POOL_SIZE_PROVIDED" = true ]; then + update_env_var "DOCKER_ADDRESS_POOL_SIZE" "$DOCKER_ADDRESS_POOL_SIZE" else - # Only update if explicitly provided - if [ "$DOCKER_POOL_BASE_PROVIDED" = true ]; then - sed -i "s|DOCKER_ADDRESS_POOL_BASE=.*|DOCKER_ADDRESS_POOL_BASE=$DOCKER_ADDRESS_POOL_BASE|g" /data/coolify/source/.env - fi -fi - -if ! grep -q "DOCKER_ADDRESS_POOL_SIZE=" /data/coolify/source/.env; then - echo "DOCKER_ADDRESS_POOL_SIZE=$DOCKER_ADDRESS_POOL_SIZE" >>/data/coolify/source/.env -else - # Only update if explicitly provided - if [ "$DOCKER_POOL_SIZE_PROVIDED" = true ]; then - sed -i "s|DOCKER_ADDRESS_POOL_SIZE=.*|DOCKER_ADDRESS_POOL_SIZE=$DOCKER_ADDRESS_POOL_SIZE|g" /data/coolify/source/.env + # Add with default value if missing + if ! grep -q "^DOCKER_ADDRESS_POOL_SIZE=" "$ENV_FILE"; then + update_env_var "DOCKER_ADDRESS_POOL_SIZE" "$DOCKER_ADDRESS_POOL_SIZE" fi fi @@ -824,14 +819,13 @@ echo -e " - Please wait." getAJoke if [[ $- == *x* ]]; then - bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" + bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" "true" else - bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" + bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" "true" fi echo " - Coolify installed successfully." -rm -f $ENV_FILE-$DATE -echo " - Waiting for 20 seconds for Coolify (database migrations) to be ready." +echo " - Waiting 20 seconds for Coolify database migrations to complete." getAJoke sleep 20 @@ -868,5 +862,5 @@ if [ -n "$PRIVATE_IPS" ]; then fi done fi + echo -e "\nWARNING: It is highly recommended to backup your Environment variables file (/data/coolify/source/.env) to a safe location, outside of this server (e.g. into a Password Manager).\n" -cp /data/coolify/source/.env /data/coolify/source/.env.backup diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index 0b031ca75..14eede4ee 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -1,11 +1,12 @@ #!/bin/bash ## Do not modify this file. You will lose the ability to autoupdate! -VERSION="15" CDN="https://cdn.coollabs.io/coolify-nightly" LATEST_IMAGE=${1:-latest} LATEST_HELPER_VERSION=${2:-latest} REGISTRY_URL=${3:-ghcr.io} +SKIP_BACKUP=${4:-false} +ENV_FILE="/data/coolify/source/.env" DATE=$(date +%Y-%m-%d-%H-%M-%S) LOGFILE="/data/coolify/source/upgrade-${DATE}.log" @@ -14,20 +15,39 @@ curl -fsSL $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml curl -fsSL $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml curl -fsSL $CDN/.env.production -o /data/coolify/source/.env.production -# Merge .env and .env.production. New values will be added to .env -awk -F '=' '!seen[$1]++' /data/coolify/source/.env /data/coolify/source/.env.production >/data/coolify/source/.env.tmp && mv /data/coolify/source/.env.tmp /data/coolify/source/.env -# Check if PUSHER_APP_ID or PUSHER_APP_KEY or PUSHER_APP_SECRET is empty in /data/coolify/source/.env -if grep -q "PUSHER_APP_ID=$" /data/coolify/source/.env; then - sed -i "s|PUSHER_APP_ID=.*|PUSHER_APP_ID=$(openssl rand -hex 32)|g" /data/coolify/source/.env +# Backup existing .env file before making any changes +if [ "$SKIP_BACKUP" != "true" ]; then + if [ -f "$ENV_FILE" ]; then + echo "Creating backup of existing .env file to .env-$DATE" >>"$LOGFILE" + cp "$ENV_FILE" "$ENV_FILE-$DATE" + else + echo "No existing .env file found to backup" >>"$LOGFILE" + fi fi -if grep -q "PUSHER_APP_KEY=$" /data/coolify/source/.env; then - sed -i "s|PUSHER_APP_KEY=.*|PUSHER_APP_KEY=$(openssl rand -hex 32)|g" /data/coolify/source/.env -fi +echo "Merging .env.production values into .env" >>"$LOGFILE" +awk -F '=' '!seen[$1]++' "$ENV_FILE" /data/coolify/source/.env.production > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" +echo ".env file merged successfully" >>"$LOGFILE" -if grep -q "PUSHER_APP_SECRET=$" /data/coolify/source/.env; then - sed -i "s|PUSHER_APP_SECRET=.*|PUSHER_APP_SECRET=$(openssl rand -hex 32)|g" /data/coolify/source/.env -fi +update_env_var() { + local key="$1" + local value="$2" + + # If variable "key=" exists but has no value, update the value of the existing line + if grep -q "^${key}=$" "$ENV_FILE"; then + sed -i "s|^${key}=$|${key}=${value}|" "$ENV_FILE" + echo " - Updated value of ${key} as the current value was empty" >>"$LOGFILE" + # If variable "key=" doesn't exist, append it to the file with value + elif ! grep -q "^${key}=" "$ENV_FILE"; then + printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE" + echo " - Added ${key} with default value as the variable was missing" >>"$LOGFILE" + fi +} + +echo "Checking and updating environment variables if necessary..." >>"$LOGFILE" +update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)" +update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)" +update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)" # Make sure coolify network exists # It is created when starting Coolify with docker compose @@ -37,11 +57,16 @@ if ! docker network inspect coolify >/dev/null 2>&1; then docker network create --attachable coolify 2>/dev/null fi fi -# docker network create --attachable --driver=overlay coolify-overlay 2>/dev/null + +# Check if Docker config file exists +DOCKER_CONFIG_MOUNT="" +if [ -f /root/.docker/config.json ]; then + DOCKER_CONFIG_MOUNT="-v /root/.docker/config.json:/root/.docker/config.json" +fi if [ -f /data/coolify/source/docker-compose.custom.yml ]; then - echo "docker-compose.custom.yml detected." >>$LOGFILE - docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>$LOGFILE 2>&1 + echo "docker-compose.custom.yml detected." >>"$LOGFILE" + docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>"$LOGFILE" 2>&1 else - docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>$LOGFILE 2>&1 + docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>"$LOGFILE" 2>&1 fi diff --git a/other/nightly/versions.json b/other/nightly/versions.json index f71aed12c..3255c215b 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.431" + "version": "4.0.0-beta.432" }, "nightly": { - "version": "4.0.0-beta.432" + "version": "4.0.0-beta.433" }, "helper": { "version": "1.0.11" diff --git a/public/coolify-logo-dev-transparent.png b/public/coolify-logo-dev-transparent.png index 9beeb9ba3..4e65e8b72 100644 Binary files a/public/coolify-logo-dev-transparent.png and b/public/coolify-logo-dev-transparent.png differ diff --git a/public/coolify-logo-dev-transparent.svg b/public/coolify-logo-dev-transparent.svg new file mode 100644 index 000000000..a4159154f --- /dev/null +++ b/public/coolify-logo-dev-transparent.svg @@ -0,0 +1 @@ +Coolify \ No newline at end of file diff --git a/public/coolify-logo-monochrome.png b/public/coolify-logo-monochrome.png new file mode 100644 index 000000000..48605e8fd Binary files /dev/null and b/public/coolify-logo-monochrome.png differ diff --git a/public/coolify-logo-monochrome.svg b/public/coolify-logo-monochrome.svg new file mode 100644 index 000000000..f60f33f97 --- /dev/null +++ b/public/coolify-logo-monochrome.svg @@ -0,0 +1 @@ +Coolify \ No newline at end of file diff --git a/public/coolify-logo-red.png b/public/coolify-logo-red.png new file mode 100644 index 000000000..b3f7d2b6c Binary files /dev/null and b/public/coolify-logo-red.png differ diff --git a/public/coolify-logo-red.svg b/public/coolify-logo-red.svg new file mode 100644 index 000000000..4cbfef43f --- /dev/null +++ b/public/coolify-logo-red.svg @@ -0,0 +1 @@ +Coolify \ No newline at end of file diff --git a/public/coolify-logo.svg b/public/coolify-logo.svg index 6f4f641f5..bff8f6b40 100644 --- a/public/coolify-logo.svg +++ b/public/coolify-logo.svg @@ -1,9 +1 @@ - - - - - - - - - +Coolify \ No newline at end of file diff --git a/public/coolify-transparent.png b/public/coolify-transparent.png index 96fc0db36..99a56acbe 100644 Binary files a/public/coolify-transparent.png and b/public/coolify-transparent.png differ diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 694ad61a3..cbbe2ef8e 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -199,7 +199,7 @@ @utility info-helper { } @utility info-helper-popup { - @apply hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300; + @apply hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 max-w-xs whitespace-normal break-words; } @utility buyme { diff --git a/resources/views/components/callout.blade.php b/resources/views/components/callout.blade.php new file mode 100644 index 000000000..e65dad63b --- /dev/null +++ b/resources/views/components/callout.blade.php @@ -0,0 +1,59 @@ +@props(['type' => 'warning', 'title' => 'Warning', 'class' => '']) + +@php + $icons = [ + 'warning' => '', + + 'danger' => '', + + 'info' => '', + + 'success' => '' + ]; + + $colors = [ + 'warning' => [ + 'bg' => 'bg-yellow-50 dark:bg-yellow-900/30', + 'border' => 'border-yellow-300 dark:border-yellow-800', + 'title' => 'text-yellow-800 dark:text-yellow-300', + 'text' => 'text-yellow-700 dark:text-yellow-200' + ], + 'danger' => [ + 'bg' => 'bg-red-50 dark:bg-red-900/30', + 'border' => 'border-red-300 dark:border-red-800', + 'title' => 'text-red-800 dark:text-red-300', + 'text' => 'text-red-700 dark:text-red-200' + ], + 'info' => [ + 'bg' => 'bg-blue-50 dark:bg-blue-900/30', + 'border' => 'border-blue-300 dark:border-blue-800', + 'title' => 'text-blue-800 dark:text-blue-300', + 'text' => 'text-blue-700 dark:text-blue-200' + ], + 'success' => [ + 'bg' => 'bg-green-50 dark:bg-green-900/30', + 'border' => 'border-green-300 dark:border-green-800', + 'title' => 'text-green-800 dark:text-green-300', + 'text' => 'text-green-700 dark:text-green-200' + ] + ]; + + $colorScheme = $colors[$type] ?? $colors['warning']; + $icon = $icons[$type] ?? $icons['warning']; +@endphp + +
merge(['class' => 'p-4 border rounded-lg ' . $colorScheme['bg'] . ' ' . $colorScheme['border'] . ' ' . $class]) }}> +
+
+ {!! $icon !!} +
+
+
+ {{ $title }} +
+
+ {{ $slot }} +
+
+
+
\ No newline at end of file diff --git a/resources/views/components/domain-conflict-modal.blade.php b/resources/views/components/domain-conflict-modal.blade.php index 218a7ef16..fe55a8ba5 100644 --- a/resources/views/components/domain-conflict-modal.blade.php +++ b/resources/views/components/domain-conflict-modal.blade.php @@ -30,14 +30,12 @@ class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-f
- + + The following domain(s) are already in use by other resources. Using the same domain for + multiple resources can cause routing conflicts and unpredictable behavior. +
-

Conflicting Resources:

    @foreach ($conflicts as $conflict)
  • @@ -58,9 +56,7 @@ class="underline hover:text-red-400">
- +
pv.toLowerCase() === lowerValue); }, get warningMessage() { if (!this.showWarning) return null; @@ -25,8 +30,8 @@ return `Recommendation: ${config.recommendation}`; } }" x-if="showWarning"> -
-
-
-
+ +
+
+
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 1c82614a6..1a3c88f80 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -201,9 +201,6 @@ class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-f @if (!empty($checkboxes))
-
-

Actions

-
@foreach ($checkboxes as $index => $checkbox)
- + + {!! $warningMessage ?: 'This operation is permanent and cannot be undone. Please think again before proceeding!' !!} +
The following actions will be performed:
    @foreach ($actions as $action) @@ -325,10 +320,9 @@ class="w-auto" isError @if (!$disableTwoStepConfirmation)
    - + + Please enter your password to confirm this destructive action. +
    @php $passwordConfirm = Str::uuid(); diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 1c5987e82..defa7bf6c 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -83,7 +83,17 @@
    - + +
    diff --git a/resources/views/components/server/sidebar-security.blade.php b/resources/views/components/server/sidebar-security.blade.php index 6f6d9d8a0..141d32f3b 100644 --- a/resources/views/components/server/sidebar-security.blade.php +++ b/resources/views/components/server/sidebar-security.blade.php @@ -3,4 +3,8 @@ href="{{ route('server.security.patches', $parameters) }}"> Server Patching + + Terminal Access +
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 47ea71ecc..bb6533932 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -4,6 +4,8 @@ @if (isSubscribed() || !isCloud()) @endif + + @auth