diff --git a/.cursor/mcp.json b/.cursor/mcp.json
new file mode 100644
index 000000000..8c6715a15
--- /dev/null
+++ b/.cursor/mcp.json
@@ -0,0 +1,11 @@
+{
+ "mcpServers": {
+ "laravel-boost": {
+ "command": "php",
+ "args": [
+ "artisan",
+ "boost:mcp"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/.cursor/rules/README.mdc b/.cursor/rules/README.mdc
index 3eb1c56fb..07f19a816 100644
--- a/.cursor/rules/README.mdc
+++ b/.cursor/rules/README.mdc
@@ -1,6 +1,6 @@
---
-description:
-globs:
+description: Complete guide to Coolify Cursor rules and development patterns
+globs: .cursor/rules/*.mdc
alwaysApply: false
---
# Coolify Cursor Rules - Complete Guide
@@ -18,6 +18,7 @@ This comprehensive set of Cursor Rules provides deep insights into **Coolify**,
### 🎨 Frontend Development
- **[frontend-patterns.mdc](mdc:.cursor/rules/frontend-patterns.mdc)** - Livewire + Alpine.js + Tailwind architecture
+- **[form-components.mdc](mdc:.cursor/rules/form-components.mdc)** - Enhanced form components with built-in authorization
### 🗄️ Data & Backend
- **[database-patterns.mdc](mdc:.cursor/rules/database-patterns.mdc)** - Database architecture, models, and data management
diff --git a/.cursor/rules/api-and-routing.mdc b/.cursor/rules/api-and-routing.mdc
index 21daf22d2..8321205ac 100644
--- a/.cursor/rules/api-and-routing.mdc
+++ b/.cursor/rules/api-and-routing.mdc
@@ -1,6 +1,6 @@
---
-description:
-globs:
+description: RESTful API design, routing patterns, webhooks, and HTTP communication
+globs: routes/*.php, app/Http/Controllers/**/*.php, app/Http/Resources/*.php, app/Http/Requests/*.php
alwaysApply: false
---
# Coolify API & Routing Architecture
diff --git a/.cursor/rules/application-architecture.mdc b/.cursor/rules/application-architecture.mdc
index 162c0840f..ef8d549ad 100644
--- a/.cursor/rules/application-architecture.mdc
+++ b/.cursor/rules/application-architecture.mdc
@@ -1,6 +1,6 @@
---
-description:
-globs:
+description: Laravel application structure, patterns, and architectural decisions
+globs: app/**/*.php, config/*.php, bootstrap/**/*.php
alwaysApply: false
---
# Coolify Application Architecture
diff --git a/.cursor/rules/database-patterns.mdc b/.cursor/rules/database-patterns.mdc
index 58934598b..a4f65f5fb 100644
--- a/.cursor/rules/database-patterns.mdc
+++ b/.cursor/rules/database-patterns.mdc
@@ -1,6 +1,6 @@
---
-description:
-globs:
+description: Database architecture, models, migrations, relationships, and data management patterns
+globs: app/Models/*.php, database/migrations/*.php, database/seeders/*.php, app/Actions/Database/*.php
alwaysApply: false
---
# Coolify Database Architecture & Patterns
diff --git a/.cursor/rules/deployment-architecture.mdc b/.cursor/rules/deployment-architecture.mdc
index 5174cbb99..35ae6699b 100644
--- a/.cursor/rules/deployment-architecture.mdc
+++ b/.cursor/rules/deployment-architecture.mdc
@@ -1,6 +1,6 @@
---
-description:
-globs:
+description: Docker orchestration, deployment workflows, and containerization patterns
+globs: app/Jobs/*.php, app/Actions/Application/*.php, app/Actions/Server/*.php, docker/*.*, *.yml, *.yaml
alwaysApply: false
---
# Coolify Deployment Architecture
diff --git a/.cursor/rules/development-workflow.mdc b/.cursor/rules/development-workflow.mdc
index dd38cbc3f..175b7d85a 100644
--- a/.cursor/rules/development-workflow.mdc
+++ b/.cursor/rules/development-workflow.mdc
@@ -1,6 +1,6 @@
---
-description:
-globs:
+description: Development setup, coding standards, contribution guidelines, and best practices
+globs: **/*.php, composer.json, package.json, *.md, .env.example
alwaysApply: false
---
# Coolify Development Workflow
diff --git a/.cursor/rules/form-components.mdc b/.cursor/rules/form-components.mdc
new file mode 100644
index 000000000..665ccfd98
--- /dev/null
+++ b/.cursor/rules/form-components.mdc
@@ -0,0 +1,452 @@
+---
+description: Enhanced form components with built-in authorization system
+globs: resources/views/**/*.blade.php, app/View/Components/Forms/*.php
+alwaysApply: true
+---
+
+# Enhanced Form Components with Authorization
+
+## Overview
+
+Coolify's form components now feature **built-in authorization** that automatically handles permission-based UI control, dramatically reducing code duplication and improving security consistency.
+
+## Enhanced Components
+
+All form components now support the `canGate` authorization system:
+
+- **[Input.php](mdc:app/View/Components/Forms/Input.php)** - Text, password, and other input fields
+- **[Select.php](mdc:app/View/Components/Forms/Select.php)** - Dropdown selection components
+- **[Textarea.php](mdc:app/View/Components/Forms/Textarea.php)** - Multi-line text areas
+- **[Checkbox.php](mdc:app/View/Components/Forms/Checkbox.php)** - Boolean toggle components
+- **[Button.php](mdc:app/View/Components/Forms/Button.php)** - Action buttons
+
+## Authorization Parameters
+
+### Core Parameters
+```php
+public ?string $canGate = null; // Gate name: 'update', 'view', 'deploy', 'delete'
+public mixed $canResource = null; // Resource model instance to check against
+public bool $autoDisable = true; // Automatically disable if no permission
+```
+
+### How It Works
+```php
+// Automatic authorization logic in each component
+if ($this->canGate && $this->canResource && $this->autoDisable) {
+ $hasPermission = Gate::allows($this->canGate, $this->canResource);
+
+ if (! $hasPermission) {
+ $this->disabled = true;
+ // For Checkbox: also sets $this->instantSave = false;
+ }
+}
+```
+
+## Usage Patterns
+
+### ✅ Recommended: Single Line Pattern
+
+**Before (Verbose, 6+ lines per element):**
+```html
+@can('update', $application)
+
File upload restricted
+ @if($currentFile) +Current: {{ $currentFile }}
+ @endif +
+
diff --git a/SECURITY.md b/SECURITY.md
index 0711bf5b5..e491737ef 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -18,7 +18,7 @@ ## Reporting a Vulnerability
If you discover a security vulnerability, please follow these steps:
1. **DO NOT** disclose the vulnerability publicly.
-2. Send a detailed report to: `hi@coollabs.io`.
+2. Send a detailed report to: `security@coollabs.io`.
3. Include in your report:
- A description of the vulnerability
- Steps to reproduce the issue
diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php
index 0ca703fce..ee3398b04 100644
--- a/app/Actions/Application/StopApplication.php
+++ b/app/Actions/Application/StopApplication.php
@@ -49,7 +49,7 @@ public function handle(Application $application, bool $previewDeployments = fals
}
if ($dockerCleanup) {
- CleanupDocker::dispatch($server, true);
+ CleanupDocker::dispatch($server, false, false);
}
} catch (\Exception $e) {
return $e->getMessage();
diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php
index a40eac17b..38d46b3c1 100644
--- a/app/Actions/Database/StartPostgresql.php
+++ b/app/Actions/Database/StartPostgresql.php
@@ -185,6 +185,8 @@ public function handle(StandalonePostgresql $database)
}
}
+ $command = ['postgres'];
+
if (filled($this->database->postgres_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
@@ -195,29 +197,25 @@ public function handle(StandalonePostgresql $database)
'read_only' => true,
]]
);
- $docker_compose['services'][$container_name]['command'] = [
- 'postgres',
- '-c',
- 'config_file=/etc/postgresql/postgresql.conf',
- ];
+ $command = array_merge($command, ['-c', 'config_file=/etc/postgresql/postgresql.conf']);
}
if ($this->database->enable_ssl) {
- $docker_compose['services'][$container_name]['command'] = [
- 'postgres',
- '-c',
- 'ssl=on',
- '-c',
- 'ssl_cert_file=/var/lib/postgresql/certs/server.crt',
- '-c',
- 'ssl_key_file=/var/lib/postgresql/certs/server.key',
- ];
+ $command = array_merge($command, [
+ '-c', 'ssl=on',
+ '-c', 'ssl_cert_file=/var/lib/postgresql/certs/server.crt',
+ '-c', 'ssl_key_file=/var/lib/postgresql/certs/server.key',
+ ]);
}
// Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
+ if (count($command) > 1) {
+ $docker_compose['services'][$container_name]['command'] = $command;
+ }
+
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
@@ -231,6 +229,8 @@ public function handle(StandalonePostgresql $database)
}
$this->commands[] = "echo 'Database started.'";
+ ray($this->commands);
+
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
}
diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php
index a03c9269e..5c881e743 100644
--- a/app/Actions/Database/StopDatabase.php
+++ b/app/Actions/Database/StopDatabase.php
@@ -18,7 +18,7 @@ class StopDatabase
{
use AsAction;
- public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $isDeleteOperation = false, bool $dockerCleanup = true)
+ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true)
{
try {
$server = $database->destination->server;
@@ -29,7 +29,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
$this->stopContainer($database, $database->uuid, 30);
if ($dockerCleanup) {
- CleanupDocker::dispatch($server, true);
+ CleanupDocker::dispatch($server, false, false);
}
if ($database->is_public) {
diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php
index c3268ec07..f5d5f82b6 100644
--- a/app/Actions/Docker/GetContainersStatus.php
+++ b/app/Actions/Docker/GetContainersStatus.php
@@ -26,6 +26,8 @@ class GetContainersStatus
public $server;
+ protected ?Collection $applicationContainerStatuses;
+
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
{
$this->containers = $containers;
@@ -94,7 +96,11 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
}
$containerStatus = data_get($container, 'State.Status');
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
- $containerStatus = "$containerStatus ($containerHealth)";
+ if ($containerStatus === 'restarting') {
+ $containerStatus = "restarting ($containerHealth)";
+ } else {
+ $containerStatus = "$containerStatus ($containerHealth)";
+ }
$labels = Arr::undot(format_docker_labels_to_json($labels));
$applicationId = data_get($labels, 'coolify.applicationId');
if ($applicationId) {
@@ -119,11 +125,16 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
$application = $this->applications->where('id', $applicationId)->first();
if ($application) {
$foundApplications[] = $application->id;
- $statusFromDb = $application->status;
- if ($statusFromDb !== $containerStatus) {
- $application->update(['status' => $containerStatus]);
- } else {
- $application->update(['last_online_at' => now()]);
+ // Store container status for aggregation
+ if (! isset($this->applicationContainerStatuses)) {
+ $this->applicationContainerStatuses = collect();
+ }
+ if (! $this->applicationContainerStatuses->has($applicationId)) {
+ $this->applicationContainerStatuses->put($applicationId, collect());
+ }
+ $containerName = data_get($labels, 'com.docker.compose.service');
+ if ($containerName) {
+ $this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
}
} else {
// Notify user that this container should not be there.
@@ -320,6 +331,97 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
}
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
}
+
+ // Aggregate multi-container application statuses
+ if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) {
+ foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
+ $application = $this->applications->where('id', $applicationId)->first();
+ if (! $application) {
+ continue;
+ }
+
+ $aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses);
+ if ($aggregatedStatus) {
+ $statusFromDb = $application->status;
+ if ($statusFromDb !== $aggregatedStatus) {
+ $application->update(['status' => $aggregatedStatus]);
+ } else {
+ $application->update(['last_online_at' => now()]);
+ }
+ }
+ }
+ }
+
ServiceChecked::dispatch($this->server->team->id);
}
+
+ private function aggregateApplicationStatus($application, Collection $containerStatuses): ?string
+ {
+ // Parse docker compose to check for excluded containers
+ $dockerComposeRaw = data_get($application, 'docker_compose_raw');
+ $excludedContainers = collect();
+
+ if ($dockerComposeRaw) {
+ try {
+ $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
+ $services = data_get($dockerCompose, 'services', []);
+
+ foreach ($services as $serviceName => $serviceConfig) {
+ // Check if container should be excluded
+ $excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
+ $restartPolicy = data_get($serviceConfig, 'restart', 'always');
+
+ if ($excludeFromHc || $restartPolicy === 'no') {
+ $excludedContainers->push($serviceName);
+ }
+ }
+ } catch (\Exception $e) {
+ // If we can't parse, treat all containers as included
+ }
+ }
+
+ // Filter out excluded containers
+ $relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
+ return ! $excludedContainers->contains($containerName);
+ });
+
+ // If all containers are excluded, don't update status
+ if ($relevantStatuses->isEmpty()) {
+ return null;
+ }
+
+ $hasRunning = false;
+ $hasRestarting = false;
+ $hasUnhealthy = false;
+ $hasExited = false;
+
+ foreach ($relevantStatuses as $status) {
+ if (str($status)->contains('restarting')) {
+ $hasRestarting = true;
+ } elseif (str($status)->contains('running')) {
+ $hasRunning = true;
+ if (str($status)->contains('unhealthy')) {
+ $hasUnhealthy = true;
+ }
+ } elseif (str($status)->contains('exited')) {
+ $hasExited = true;
+ $hasUnhealthy = true;
+ }
+ }
+
+ if ($hasRestarting) {
+ return 'degraded (unhealthy)';
+ }
+
+ if ($hasRunning && $hasExited) {
+ return 'degraded (unhealthy)';
+ }
+
+ if ($hasRunning) {
+ return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
+ }
+
+ // All containers are exited
+ return 'exited (unhealthy)';
+ }
}
diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php
index ea2befd3a..9f97dd0d4 100644
--- a/app/Actions/Fortify/CreateNewUser.php
+++ b/app/Actions/Fortify/CreateNewUser.php
@@ -40,7 +40,7 @@ public function create(array $input): User
$user = User::create([
'id' => 0,
'name' => $input['name'],
- 'email' => strtolower($input['email']),
+ 'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
$team = $user->teams()->first();
@@ -52,7 +52,7 @@ public function create(array $input): User
} else {
$user = User::create([
'name' => $input['name'],
- 'email' => strtolower($input['email']),
+ 'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
$team = $user->teams()->first();
diff --git a/app/Actions/Proxy/CheckConfiguration.php b/app/Actions/Proxy/CheckConfiguration.php
deleted file mode 100644
index b2d1eb787..000000000
--- a/app/Actions/Proxy/CheckConfiguration.php
+++ /dev/null
@@ -1,36 +0,0 @@
-proxyType();
- if ($proxyType === 'NONE') {
- return 'OK';
- }
- $proxy_path = $server->proxyPath();
- $payload = [
- "mkdir -p $proxy_path",
- "cat $proxy_path/docker-compose.yml",
- ];
- $proxy_configuration = instant_remote_process($payload, $server, false);
- if ($reset || ! $proxy_configuration || is_null($proxy_configuration)) {
- $proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value();
- }
- if (! $proxy_configuration || is_null($proxy_configuration)) {
- throw new \Exception('Could not generate proxy configuration');
- }
-
- ProxyDashboardCacheService::isTraefikDashboardAvailableFromConfiguration($server, $proxy_configuration);
-
- return $proxy_configuration;
- }
-}
diff --git a/app/Actions/Proxy/CheckProxy.php b/app/Actions/Proxy/CheckProxy.php
index d4b03ffc1..99537e606 100644
--- a/app/Actions/Proxy/CheckProxy.php
+++ b/app/Actions/Proxy/CheckProxy.php
@@ -66,11 +66,11 @@ public function handle(Server $server, $fromUI = false): bool
if ($server->id === 0) {
$ip = 'host.docker.internal';
}
- $portsToCheck = ['80', '443'];
+ $portsToCheck = [];
try {
if ($server->proxyType() !== ProxyTypes::NONE->value) {
- $proxyCompose = CheckConfiguration::run($server);
+ $proxyCompose = GetProxyConfiguration::run($server);
if (isset($proxyCompose)) {
$yaml = Yaml::parse($proxyCompose);
$configPorts = [];
diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php
new file mode 100644
index 000000000..3bf91c281
--- /dev/null
+++ b/app/Actions/Proxy/GetProxyConfiguration.php
@@ -0,0 +1,47 @@
+proxyType();
+ if ($proxyType === 'NONE') {
+ return 'OK';
+ }
+
+ $proxy_path = $server->proxyPath();
+ $proxy_configuration = null;
+
+ // If not forcing regeneration, try to read existing configuration
+ if (! $forceRegenerate) {
+ $payload = [
+ "mkdir -p $proxy_path",
+ "cat $proxy_path/docker-compose.yml 2>/dev/null",
+ ];
+ $proxy_configuration = instant_remote_process($payload, $server, false);
+ }
+
+ // Generate default configuration if:
+ // 1. Force regenerate is requested
+ // 2. Configuration file doesn't exist or is empty
+ if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) {
+ $proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value();
+ }
+
+ if (empty($proxy_configuration)) {
+ throw new \Exception('Could not get or generate proxy configuration');
+ }
+
+ ProxyDashboardCacheService::isTraefikDashboardAvailableFromConfiguration($server, $proxy_configuration);
+
+ return $proxy_configuration;
+ }
+}
diff --git a/app/Actions/Proxy/SaveConfiguration.php b/app/Actions/Proxy/SaveProxyConfiguration.php
similarity index 59%
rename from app/Actions/Proxy/SaveConfiguration.php
rename to app/Actions/Proxy/SaveProxyConfiguration.php
index f2de2b3f5..53fbecce2 100644
--- a/app/Actions/Proxy/SaveConfiguration.php
+++ b/app/Actions/Proxy/SaveProxyConfiguration.php
@@ -5,22 +5,21 @@
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
-class SaveConfiguration
+class SaveProxyConfiguration
{
use AsAction;
- public function handle(Server $server, ?string $proxy_settings = null)
+ public function handle(Server $server, string $configuration): void
{
- if (is_null($proxy_settings)) {
- $proxy_settings = CheckConfiguration::run($server, true);
- }
$proxy_path = $server->proxyPath();
- $docker_compose_yml_base64 = base64_encode($proxy_settings);
+ $docker_compose_yml_base64 = base64_encode($configuration);
+ // Update the saved settings hash
$server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value;
$server->save();
- return instant_remote_process([
+ // Transfer the configuration file to the server
+ instant_remote_process([
"mkdir -p $proxy_path",
"echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null",
], $server);
diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php
index e7c020ff6..ecfb13d0b 100644
--- a/app/Actions/Proxy/StartProxy.php
+++ b/app/Actions/Proxy/StartProxy.php
@@ -21,11 +21,11 @@ public function handle(Server $server, bool $async = true, bool $force = false):
}
$commands = collect([]);
$proxy_path = $server->proxyPath();
- $configuration = CheckConfiguration::run($server);
+ $configuration = GetProxyConfiguration::run($server);
if (! $configuration) {
throw new \Exception('Configuration is not synced');
}
- SaveConfiguration::run($server, $configuration);
+ SaveProxyConfiguration::run($server, $configuration);
$docker_compose_yml_base64 = base64_encode($configuration);
$server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value();
$server->save();
diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php
index a8b1be11d..6823dfb92 100644
--- a/app/Actions/Server/CheckUpdates.php
+++ b/app/Actions/Server/CheckUpdates.php
@@ -102,7 +102,6 @@ public function handle(Server $server)
];
}
} catch (\Throwable $e) {
- ray('Error:', $e->getMessage());
return [
'osId' => $osId,
diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php
index 754feecb1..392562167 100644
--- a/app/Actions/Server/CleanupDocker.php
+++ b/app/Actions/Server/CleanupDocker.php
@@ -11,7 +11,7 @@ class CleanupDocker
public string $jobQueue = 'high';
- public function handle(Server $server)
+ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false)
{
$settings = instanceSettings();
$realtimeImage = config('constants.coolify.realtime_image');
@@ -36,11 +36,11 @@ public function handle(Server $server)
"docker images --filter before=$realtimeImageWithoutPrefixVersion --filter reference=$realtimeImageWithoutPrefix | grep $realtimeImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
];
- if ($server->settings->delete_unused_volumes) {
+ if ($deleteUnusedVolumes) {
$commands[] = 'docker volume prune -af';
}
- if ($server->settings->delete_unused_networks) {
+ if ($deleteUnusedNetworks) {
$commands[] = 'docker network prune -f';
}
diff --git a/app/Actions/Server/ServerCheck.php b/app/Actions/Server/ServerCheck.php
deleted file mode 100644
index 6ac87f1f0..000000000
--- a/app/Actions/Server/ServerCheck.php
+++ /dev/null
@@ -1,268 +0,0 @@
-server = $server;
- try {
- if ($this->server->isFunctional() === false) {
- return 'Server is not functional.';
- }
-
- if (! $this->server->isSwarmWorker() && ! $this->server->isBuildServer()) {
-
- if (isset($data)) {
- $data = collect($data);
-
- $this->server->sentinelHeartbeat();
-
- $this->containers = collect(data_get($data, 'containers'));
-
- $filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
- ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
-
- $containerReplicates = null;
- $this->isSentinel = true;
- } else {
- ['containers' => $this->containers, 'containerReplicates' => $containerReplicates] = $this->server->getContainers();
- // ServerStorageCheckJob::dispatch($this->server);
- }
-
- if (is_null($this->containers)) {
- return 'No containers found.';
- }
-
- if (isset($containerReplicates)) {
- foreach ($containerReplicates as $containerReplica) {
- $name = data_get($containerReplica, 'Name');
- $this->containers = $this->containers->map(function ($container) use ($name, $containerReplica) {
- if (data_get($container, 'Spec.Name') === $name) {
- $replicas = data_get($containerReplica, 'Replicas');
- $running = str($replicas)->explode('/')[0];
- $total = str($replicas)->explode('/')[1];
- if ($running === $total) {
- data_set($container, 'State.Status', 'running');
- data_set($container, 'State.Health.Status', 'healthy');
- } else {
- data_set($container, 'State.Status', 'starting');
- data_set($container, 'State.Health.Status', 'unhealthy');
- }
- }
-
- return $container;
- });
- }
- }
- $this->checkContainers();
-
- if ($this->server->isSentinelEnabled() && $this->isSentinel === false) {
- CheckAndStartSentinelJob::dispatch($this->server);
- }
-
- if ($this->server->isLogDrainEnabled()) {
- $this->checkLogDrainContainer();
- }
-
- if ($this->server->proxySet() && ! $this->server->proxy->force_stop) {
- $foundProxyContainer = $this->containers->filter(function ($value, $key) {
- if ($this->server->isSwarm()) {
- return data_get($value, 'Spec.Name') === 'coolify-proxy_traefik';
- } else {
- return data_get($value, 'Name') === '/coolify-proxy';
- }
- })->first();
- $proxyStatus = data_get($foundProxyContainer, 'State.Status', 'exited');
- if (! $foundProxyContainer || $proxyStatus !== 'running') {
- try {
- $shouldStart = CheckProxy::run($this->server);
- if ($shouldStart) {
- StartProxy::run($this->server, async: false);
- $this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server));
- }
- } catch (\Throwable $e) {
- }
- } else {
- $this->server->proxy->status = data_get($foundProxyContainer, 'State.Status');
- $this->server->save();
- $connectProxyToDockerNetworks = connectProxyToNetworks($this->server);
- instant_remote_process($connectProxyToDockerNetworks, $this->server, false);
- }
- }
- }
- } catch (\Throwable $e) {
- return handleError($e);
- }
- }
-
- private function checkLogDrainContainer()
- {
- $foundLogDrainContainer = $this->containers->filter(function ($value, $key) {
- return data_get($value, 'Name') === '/coolify-log-drain';
- })->first();
- if ($foundLogDrainContainer) {
- $status = data_get($foundLogDrainContainer, 'State.Status');
- if ($status !== 'running') {
- StartLogDrain::dispatch($this->server);
- }
- } else {
- StartLogDrain::dispatch($this->server);
- }
- }
-
- private function checkContainers()
- {
- foreach ($this->containers as $container) {
- if ($this->isSentinel) {
- $labels = Arr::undot(data_get($container, 'labels'));
- } else {
- if ($this->server->isSwarm()) {
- $labels = Arr::undot(data_get($container, 'Spec.Labels'));
- } else {
- $labels = Arr::undot(data_get($container, 'Config.Labels'));
- }
- }
- $managed = data_get($labels, 'coolify.managed');
- if (! $managed) {
- continue;
- }
- $uuid = data_get($labels, 'coolify.name');
- if (! $uuid) {
- $uuid = data_get($labels, 'com.docker.compose.service');
- }
-
- if ($this->isSentinel) {
- $containerStatus = data_get($container, 'state');
- $containerHealth = data_get($container, 'health_status');
- } else {
- $containerStatus = data_get($container, 'State.Status');
- $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
- }
- $containerStatus = "$containerStatus ($containerHealth)";
-
- $applicationId = data_get($labels, 'coolify.applicationId');
- $serviceId = data_get($labels, 'coolify.serviceId');
- $databaseId = data_get($labels, 'coolify.databaseId');
- $pullRequestId = data_get($labels, 'coolify.pullRequestId');
-
- if ($applicationId) {
- // Application
- if ($pullRequestId != 0) {
- if (str($applicationId)->contains('-')) {
- $applicationId = str($applicationId)->before('-');
- }
- $preview = ApplicationPreview::where('application_id', $applicationId)->where('pull_request_id', $pullRequestId)->first();
- if ($preview) {
- $preview->update(['status' => $containerStatus]);
- }
- } else {
- $application = Application::where('id', $applicationId)->first();
- if ($application) {
- $application->update([
- 'status' => $containerStatus,
- 'last_online_at' => now(),
- ]);
- }
- }
- } elseif (isset($serviceId)) {
- // Service
- $subType = data_get($labels, 'coolify.service.subType');
- $subId = data_get($labels, 'coolify.service.subId');
- $service = Service::where('id', $serviceId)->first();
- if (! $service) {
- continue;
- }
- if ($subType === 'application') {
- $service = ServiceApplication::where('id', $subId)->first();
- } else {
- $service = ServiceDatabase::where('id', $subId)->first();
- }
- if ($service) {
- $service->update([
- 'status' => $containerStatus,
- 'last_online_at' => now(),
- ]);
- if ($subType === 'database') {
- $isPublic = data_get($service, 'is_public');
- if ($isPublic) {
- $foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
- if ($this->isSentinel) {
- return data_get($value, 'name') === $uuid.'-proxy';
- } else {
-
- if ($this->server->isSwarm()) {
- return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
- } else {
- return data_get($value, 'Name') === "/$uuid-proxy";
- }
- }
- })->first();
- if (! $foundTcpProxy) {
- StartDatabaseProxy::run($service);
- }
- }
- }
- }
- } else {
- // Database
- if (is_null($this->databases)) {
- $this->databases = $this->server->databases();
- }
- $database = $this->databases->where('uuid', $uuid)->first();
- if ($database) {
- $database->update([
- 'status' => $containerStatus,
- 'last_online_at' => now(),
- ]);
-
- $isPublic = data_get($database, 'is_public');
- if ($isPublic) {
- $foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
- if ($this->isSentinel) {
- return data_get($value, 'name') === $uuid.'-proxy';
- } else {
- if ($this->server->isSwarm()) {
- return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
- } else {
-
- return data_get($value, 'Name') === "/$uuid-proxy";
- }
- }
- })->first();
- if (! $foundTcpProxy) {
- StartDatabaseProxy::run($database);
- // $this->server->team?->notify(new ContainerRestarted("TCP Proxy for database", $this->server));
- }
- }
- }
- }
- }
- }
-}
diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php
index 1ecf882dc..1f248aec1 100644
--- a/app/Actions/Server/StartSentinel.php
+++ b/app/Actions/Server/StartSentinel.php
@@ -2,6 +2,7 @@
namespace App\Actions\Server;
+use App\Events\SentinelRestarted;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
@@ -9,7 +10,7 @@ class StartSentinel
{
use AsAction;
- public function handle(Server $server, bool $restart = false, ?string $latestVersion = null)
+ public function handle(Server $server, bool $restart = false, ?string $latestVersion = null, ?string $customImage = null)
{
if ($server->isSwarm() || $server->isBuildServer()) {
return;
@@ -43,7 +44,9 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
];
if (isDev()) {
// data_set($environments, 'DEBUG', 'true');
- // $image = 'sentinel';
+ if ($customImage && ! empty($customImage)) {
+ $image = $customImage;
+ }
$mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel';
}
$dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"';
@@ -61,5 +64,8 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
$server->settings->is_sentinel_enabled = true;
$server->settings->save();
$server->sentinelHeartbeat();
+
+ // Dispatch event to notify UI components
+ SentinelRestarted::dispatch($server, $version);
}
}
diff --git a/app/Actions/Server/UpdateCoolify.php b/app/Actions/Server/UpdateCoolify.php
index 9a6cc140b..2a06428e2 100644
--- a/app/Actions/Server/UpdateCoolify.php
+++ b/app/Actions/Server/UpdateCoolify.php
@@ -29,7 +29,7 @@ public function handle($manual_update = false)
if (! $this->server) {
return;
}
- CleanupDocker::dispatch($this->server);
+ CleanupDocker::dispatch($this->server, false, false);
$this->latestVersion = get_latest_version_of_coolify();
$this->currentVersion = config('constants.coolify.version');
if (! $manual_update) {
diff --git a/app/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php
index 404e11559..8790901cd 100644
--- a/app/Actions/Service/DeleteService.php
+++ b/app/Actions/Service/DeleteService.php
@@ -11,7 +11,7 @@ class DeleteService
{
use AsAction;
- public function handle(Service $service, bool $deleteConfigurations, bool $deleteVolumes, bool $dockerCleanup, bool $deleteConnectedNetworks)
+ public function handle(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations, bool $dockerCleanup)
{
try {
$server = data_get($service, 'server');
@@ -71,7 +71,7 @@ public function handle(Service $service, bool $deleteConfigurations, bool $delet
$service->forceDelete();
if ($dockerCleanup) {
- CleanupDocker::dispatch($server, true);
+ CleanupDocker::dispatch($server, false, false);
}
}
}
diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php
index a7fa4b8b2..3f4e96479 100644
--- a/app/Actions/Service/StopService.php
+++ b/app/Actions/Service/StopService.php
@@ -14,7 +14,7 @@ class StopService
public string $jobQueue = 'high';
- public function handle(Service $service, bool $isDeleteOperation = false, bool $dockerCleanup = true)
+ public function handle(Service $service, bool $deleteConnectedNetworks = false, bool $dockerCleanup = true)
{
try {
$server = $service->destination->server;
@@ -36,11 +36,11 @@ public function handle(Service $service, bool $isDeleteOperation = false, bool $
$this->stopContainersInParallel($containersToStop, $server);
}
- if ($isDeleteOperation) {
+ if ($deleteConnectedNetworks) {
$service->deleteConnectedNetworks();
}
if ($dockerCleanup) {
- CleanupDocker::dispatch($server, true);
+ CleanupDocker::dispatch($server, false, false);
}
} catch (\Exception $e) {
return $e->getMessage();
diff --git a/app/Actions/Shared/ComplexStatusCheck.php b/app/Actions/Shared/ComplexStatusCheck.php
index 5a7ba6637..e06136e3c 100644
--- a/app/Actions/Shared/ComplexStatusCheck.php
+++ b/app/Actions/Shared/ComplexStatusCheck.php
@@ -26,22 +26,22 @@ public function handle(Application $application)
continue;
}
}
- $container = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false);
- $container = format_docker_command_output_to_json($container);
- if ($container->count() === 1) {
- $container = $container->first();
- $containerStatus = data_get($container, 'State.Status');
- $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
+ $containers = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false);
+ $containers = format_docker_command_output_to_json($containers);
+
+ if ($containers->count() > 0) {
+ $statusToSet = $this->aggregateContainerStatuses($application, $containers);
+
if ($is_main_server) {
$statusFromDb = $application->status;
- if ($statusFromDb !== $containerStatus) {
- $application->update(['status' => "$containerStatus:$containerHealth"]);
+ if ($statusFromDb !== $statusToSet) {
+ $application->update(['status' => $statusToSet]);
}
} else {
$additional_server = $application->additional_servers()->wherePivot('server_id', $server->id);
$statusFromDb = $additional_server->first()->pivot->status;
- if ($statusFromDb !== $containerStatus) {
- $additional_server->updateExistingPivot($server->id, ['status' => "$containerStatus:$containerHealth"]);
+ if ($statusFromDb !== $statusToSet) {
+ $additional_server->updateExistingPivot($server->id, ['status' => $statusToSet]);
}
}
} else {
@@ -57,4 +57,78 @@ public function handle(Application $application)
}
}
}
+
+ private function aggregateContainerStatuses($application, $containers)
+ {
+ $dockerComposeRaw = data_get($application, 'docker_compose_raw');
+ $excludedContainers = collect();
+
+ if ($dockerComposeRaw) {
+ try {
+ $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
+ $services = data_get($dockerCompose, 'services', []);
+
+ foreach ($services as $serviceName => $serviceConfig) {
+ $excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
+ $restartPolicy = data_get($serviceConfig, 'restart', 'always');
+
+ if ($excludeFromHc || $restartPolicy === 'no') {
+ $excludedContainers->push($serviceName);
+ }
+ }
+ } catch (\Exception $e) {
+ // If we can't parse, treat all containers as included
+ }
+ }
+
+ $hasRunning = false;
+ $hasRestarting = false;
+ $hasUnhealthy = false;
+ $hasExited = false;
+ $relevantContainerCount = 0;
+
+ foreach ($containers as $container) {
+ $labels = data_get($container, 'Config.Labels', []);
+ $serviceName = data_get($labels, 'com.docker.compose.service');
+
+ if ($serviceName && $excludedContainers->contains($serviceName)) {
+ continue;
+ }
+
+ $relevantContainerCount++;
+ $containerStatus = data_get($container, 'State.Status');
+ $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
+
+ if ($containerStatus === 'restarting') {
+ $hasRestarting = true;
+ $hasUnhealthy = true;
+ } elseif ($containerStatus === 'running') {
+ $hasRunning = true;
+ if ($containerHealth === 'unhealthy') {
+ $hasUnhealthy = true;
+ }
+ } elseif ($containerStatus === 'exited') {
+ $hasExited = true;
+ $hasUnhealthy = true;
+ }
+ }
+
+ if ($relevantContainerCount === 0) {
+ return 'running:healthy';
+ }
+
+ if ($hasRestarting) {
+ return 'degraded:unhealthy';
+ }
+
+ if ($hasRunning && $hasExited) {
+ return 'degraded:unhealthy';
+ }
+
+ if ($hasRunning) {
+ return $hasUnhealthy ? 'running:unhealthy' : 'running:healthy';
+ }
+
+ return 'exited:unhealthy';
+ }
}
diff --git a/app/Actions/Stripe/CancelSubscription.php b/app/Actions/Stripe/CancelSubscription.php
new file mode 100644
index 000000000..859aec6f6
--- /dev/null
+++ b/app/Actions/Stripe/CancelSubscription.php
@@ -0,0 +1,151 @@
+user = $user;
+ $this->isDryRun = $isDryRun;
+
+ if (! $isDryRun && isCloud()) {
+ $this->stripe = new StripeClient(config('subscription.stripe_api_key'));
+ }
+ }
+
+ public function getSubscriptionsPreview(): Collection
+ {
+ $subscriptions = collect();
+
+ // Get all teams the user belongs to
+ $teams = $this->user->teams;
+
+ foreach ($teams as $team) {
+ // Only include subscriptions from teams where user is owner
+ $userRole = $team->pivot->role;
+ if ($userRole === 'owner' && $team->subscription) {
+ $subscription = $team->subscription;
+
+ // Only include active subscriptions
+ if ($subscription->stripe_subscription_id &&
+ $subscription->stripe_invoice_paid) {
+ $subscriptions->push($subscription);
+ }
+ }
+ }
+
+ return $subscriptions;
+ }
+
+ public function execute(): array
+ {
+ if ($this->isDryRun) {
+ return [
+ 'cancelled' => 0,
+ 'failed' => 0,
+ 'errors' => [],
+ ];
+ }
+
+ $cancelledCount = 0;
+ $failedCount = 0;
+ $errors = [];
+
+ $subscriptions = $this->getSubscriptionsPreview();
+
+ foreach ($subscriptions as $subscription) {
+ try {
+ $this->cancelSingleSubscription($subscription);
+ $cancelledCount++;
+ } catch (\Exception $e) {
+ $failedCount++;
+ $errorMessage = "Failed to cancel subscription {$subscription->stripe_subscription_id}: ".$e->getMessage();
+ $errors[] = $errorMessage;
+ \Log::error($errorMessage);
+ }
+ }
+
+ return [
+ 'cancelled' => $cancelledCount,
+ 'failed' => $failedCount,
+ 'errors' => $errors,
+ ];
+ }
+
+ private function cancelSingleSubscription(Subscription $subscription): void
+ {
+ if (! $this->stripe) {
+ throw new \Exception('Stripe client not initialized');
+ }
+
+ $subscriptionId = $subscription->stripe_subscription_id;
+
+ // Cancel the subscription immediately (not at period end)
+ $this->stripe->subscriptions->cancel($subscriptionId, []);
+
+ // Update local database
+ $subscription->update([
+ 'stripe_cancel_at_period_end' => false,
+ 'stripe_invoice_paid' => false,
+ 'stripe_trial_already_ended' => false,
+ 'stripe_past_due' => false,
+ 'stripe_feedback' => 'User account deleted',
+ 'stripe_comment' => 'Subscription cancelled due to user account deletion at '.now()->toDateTimeString(),
+ ]);
+
+ // Call the team's subscription ended method to handle cleanup
+ if ($subscription->team) {
+ $subscription->team->subscriptionEnded();
+ }
+
+ \Log::info("Cancelled Stripe subscription: {$subscriptionId} for team: {$subscription->team->name}");
+ }
+
+ /**
+ * Cancel a single subscription by ID (helper method for external use)
+ */
+ public static function cancelById(string $subscriptionId): bool
+ {
+ try {
+ if (! isCloud()) {
+ return false;
+ }
+
+ $stripe = new StripeClient(config('subscription.stripe_api_key'));
+ $stripe->subscriptions->cancel($subscriptionId, []);
+
+ // Update local record if exists
+ $subscription = Subscription::where('stripe_subscription_id', $subscriptionId)->first();
+ if ($subscription) {
+ $subscription->update([
+ 'stripe_cancel_at_period_end' => false,
+ 'stripe_invoice_paid' => false,
+ 'stripe_trial_already_ended' => false,
+ 'stripe_past_due' => false,
+ ]);
+
+ if ($subscription->team) {
+ $subscription->team->subscriptionEnded();
+ }
+ }
+
+ return true;
+ } catch (\Exception $e) {
+ \Log::error("Failed to cancel subscription {$subscriptionId}: ".$e->getMessage());
+
+ return false;
+ }
+ }
+}
diff --git a/app/Actions/User/DeleteUserResources.php b/app/Actions/User/DeleteUserResources.php
new file mode 100644
index 000000000..7b2e7318d
--- /dev/null
+++ b/app/Actions/User/DeleteUserResources.php
@@ -0,0 +1,125 @@
+user = $user;
+ $this->isDryRun = $isDryRun;
+ }
+
+ public function getResourcesPreview(): array
+ {
+ $applications = collect();
+ $databases = collect();
+ $services = collect();
+
+ // Get all teams the user belongs to
+ $teams = $this->user->teams;
+
+ foreach ($teams as $team) {
+ // Get all servers for this team
+ $servers = $team->servers;
+
+ foreach ($servers as $server) {
+ // Get applications
+ $serverApplications = $server->applications;
+ $applications = $applications->merge($serverApplications);
+
+ // Get databases
+ $serverDatabases = $this->getAllDatabasesForServer($server);
+ $databases = $databases->merge($serverDatabases);
+
+ // Get services
+ $serverServices = $server->services;
+ $services = $services->merge($serverServices);
+ }
+ }
+
+ return [
+ 'applications' => $applications->unique('id'),
+ 'databases' => $databases->unique('id'),
+ 'services' => $services->unique('id'),
+ ];
+ }
+
+ public function execute(): array
+ {
+ if ($this->isDryRun) {
+ return [
+ 'applications' => 0,
+ 'databases' => 0,
+ 'services' => 0,
+ ];
+ }
+
+ $deletedCounts = [
+ 'applications' => 0,
+ 'databases' => 0,
+ 'services' => 0,
+ ];
+
+ $resources = $this->getResourcesPreview();
+
+ // Delete applications
+ foreach ($resources['applications'] as $application) {
+ try {
+ $application->forceDelete();
+ $deletedCounts['applications']++;
+ } catch (\Exception $e) {
+ \Log::error("Failed to delete application {$application->id}: ".$e->getMessage());
+ throw $e; // Re-throw to trigger rollback
+ }
+ }
+
+ // Delete databases
+ foreach ($resources['databases'] as $database) {
+ try {
+ $database->forceDelete();
+ $deletedCounts['databases']++;
+ } catch (\Exception $e) {
+ \Log::error("Failed to delete database {$database->id}: ".$e->getMessage());
+ throw $e; // Re-throw to trigger rollback
+ }
+ }
+
+ // Delete services
+ foreach ($resources['services'] as $service) {
+ try {
+ $service->forceDelete();
+ $deletedCounts['services']++;
+ } catch (\Exception $e) {
+ \Log::error("Failed to delete service {$service->id}: ".$e->getMessage());
+ throw $e; // Re-throw to trigger rollback
+ }
+ }
+
+ return $deletedCounts;
+ }
+
+ private function getAllDatabasesForServer($server): Collection
+ {
+ $databases = collect();
+
+ // Get all standalone database types
+ $databases = $databases->merge($server->postgresqls);
+ $databases = $databases->merge($server->mysqls);
+ $databases = $databases->merge($server->mariadbs);
+ $databases = $databases->merge($server->mongodbs);
+ $databases = $databases->merge($server->redis);
+ $databases = $databases->merge($server->keydbs);
+ $databases = $databases->merge($server->dragonflies);
+ $databases = $databases->merge($server->clickhouses);
+
+ return $databases;
+ }
+}
diff --git a/app/Actions/User/DeleteUserServers.php b/app/Actions/User/DeleteUserServers.php
new file mode 100644
index 000000000..d8caae54d
--- /dev/null
+++ b/app/Actions/User/DeleteUserServers.php
@@ -0,0 +1,77 @@
+user = $user;
+ $this->isDryRun = $isDryRun;
+ }
+
+ public function getServersPreview(): Collection
+ {
+ $servers = collect();
+
+ // Get all teams the user belongs to
+ $teams = $this->user->teams;
+
+ foreach ($teams as $team) {
+ // Only include servers from teams where user is owner or admin
+ $userRole = $team->pivot->role;
+ if ($userRole === 'owner' || $userRole === 'admin') {
+ $teamServers = $team->servers;
+ $servers = $servers->merge($teamServers);
+ }
+ }
+
+ // Return unique servers (in case same server is in multiple teams)
+ return $servers->unique('id');
+ }
+
+ public function execute(): array
+ {
+ if ($this->isDryRun) {
+ return [
+ 'servers' => 0,
+ ];
+ }
+
+ $deletedCount = 0;
+
+ $servers = $this->getServersPreview();
+
+ foreach ($servers as $server) {
+ try {
+ // Skip the default server (ID 0) which is the Coolify host
+ if ($server->id === 0) {
+ \Log::info('Skipping deletion of Coolify host server (ID: 0)');
+
+ continue;
+ }
+
+ // The Server model's forceDeleting event will handle cleanup of:
+ // - destinations
+ // - settings
+ $server->forceDelete();
+ $deletedCount++;
+ } catch (\Exception $e) {
+ \Log::error("Failed to delete server {$server->id}: ".$e->getMessage());
+ throw $e; // Re-throw to trigger rollback
+ }
+ }
+
+ return [
+ 'servers' => $deletedCount,
+ ];
+ }
+}
diff --git a/app/Actions/User/DeleteUserTeams.php b/app/Actions/User/DeleteUserTeams.php
new file mode 100644
index 000000000..d572db9e7
--- /dev/null
+++ b/app/Actions/User/DeleteUserTeams.php
@@ -0,0 +1,202 @@
+user = $user;
+ $this->isDryRun = $isDryRun;
+ }
+
+ public function getTeamsPreview(): array
+ {
+ $teamsToDelete = collect();
+ $teamsToTransfer = collect();
+ $teamsToLeave = collect();
+ $edgeCases = collect();
+
+ $teams = $this->user->teams;
+
+ foreach ($teams as $team) {
+ // Skip root team (ID 0)
+ if ($team->id === 0) {
+ continue;
+ }
+
+ $userRole = $team->pivot->role;
+ $memberCount = $team->members->count();
+
+ if ($memberCount === 1) {
+ // User is alone in the team - delete it
+ $teamsToDelete->push($team);
+ } elseif ($userRole === 'owner') {
+ // Check if there are other owners
+ $otherOwners = $team->members
+ ->where('id', '!=', $this->user->id)
+ ->filter(function ($member) {
+ return $member->pivot->role === 'owner';
+ });
+
+ if ($otherOwners->isNotEmpty()) {
+ // There are other owners, but check if this user is paying for the subscription
+ if ($this->isUserPayingForTeamSubscription($team)) {
+ // User is paying for the subscription - this is an edge case
+ $edgeCases->push([
+ 'team' => $team,
+ 'reason' => 'User is paying for the team\'s Stripe subscription but there are other owners. The subscription needs to be cancelled or transferred to another owner\'s payment method.',
+ ]);
+ } else {
+ // There are other owners and user is not paying, just remove this user
+ $teamsToLeave->push($team);
+ }
+ } else {
+ // User is the only owner, check for replacement
+ $newOwner = $this->findNewOwner($team);
+ if ($newOwner) {
+ $teamsToTransfer->push([
+ 'team' => $team,
+ 'new_owner' => $newOwner,
+ ]);
+ } else {
+ // No suitable replacement found - this is an edge case
+ $edgeCases->push([
+ 'team' => $team,
+ 'reason' => 'No suitable owner replacement found. Team has only regular members without admin privileges.',
+ ]);
+ }
+ }
+ } else {
+ // User is just a member - remove them from the team
+ $teamsToLeave->push($team);
+ }
+ }
+
+ return [
+ 'to_delete' => $teamsToDelete,
+ 'to_transfer' => $teamsToTransfer,
+ 'to_leave' => $teamsToLeave,
+ 'edge_cases' => $edgeCases,
+ ];
+ }
+
+ public function execute(): array
+ {
+ if ($this->isDryRun) {
+ return [
+ 'deleted' => 0,
+ 'transferred' => 0,
+ 'left' => 0,
+ ];
+ }
+
+ $counts = [
+ 'deleted' => 0,
+ 'transferred' => 0,
+ 'left' => 0,
+ ];
+
+ $preview = $this->getTeamsPreview();
+
+ // Check for edge cases - should not happen here as we check earlier, but be safe
+ if ($preview['edge_cases']->isNotEmpty()) {
+ throw new \Exception('Edge cases detected during execution. This should not happen.');
+ }
+
+ // Delete teams where user is alone
+ foreach ($preview['to_delete'] as $team) {
+ try {
+ // The Team model's deleting event will handle cleanup of:
+ // - private keys
+ // - sources
+ // - tags
+ // - environment variables
+ // - s3 storages
+ // - notification settings
+ $team->delete();
+ $counts['deleted']++;
+ } catch (\Exception $e) {
+ \Log::error("Failed to delete team {$team->id}: ".$e->getMessage());
+ throw $e; // Re-throw to trigger rollback
+ }
+ }
+
+ // Transfer ownership for teams where user is owner but not alone
+ foreach ($preview['to_transfer'] as $item) {
+ try {
+ $team = $item['team'];
+ $newOwner = $item['new_owner'];
+
+ // Update the new owner's role to owner
+ $team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']);
+
+ // Remove the current user from the team
+ $team->members()->detach($this->user->id);
+
+ $counts['transferred']++;
+ } catch (\Exception $e) {
+ \Log::error("Failed to transfer ownership of team {$item['team']->id}: ".$e->getMessage());
+ throw $e; // Re-throw to trigger rollback
+ }
+ }
+
+ // Remove user from teams where they're just a member
+ foreach ($preview['to_leave'] as $team) {
+ try {
+ $team->members()->detach($this->user->id);
+ $counts['left']++;
+ } catch (\Exception $e) {
+ \Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage());
+ throw $e; // Re-throw to trigger rollback
+ }
+ }
+
+ return $counts;
+ }
+
+ private function findNewOwner(Team $team): ?User
+ {
+ // Only look for admins as potential new owners
+ // We don't promote regular members automatically
+ $otherAdmin = $team->members
+ ->where('id', '!=', $this->user->id)
+ ->filter(function ($member) {
+ return $member->pivot->role === 'admin';
+ })
+ ->first();
+
+ return $otherAdmin;
+ }
+
+ private function isUserPayingForTeamSubscription(Team $team): bool
+ {
+ if (! $team->subscription || ! $team->subscription->stripe_customer_id) {
+ return false;
+ }
+
+ // In Stripe, we need to check if the customer email matches the user's email
+ // This would require a Stripe API call to get customer details
+ // For now, we'll check if the subscription was created by this user
+
+ // Alternative approach: Check if user is the one who initiated the subscription
+ // We could store this information when the subscription is created
+ // For safety, we'll assume if there's an active subscription and multiple owners,
+ // we should treat it as an edge case that needs manual review
+
+ if ($team->subscription->stripe_subscription_id &&
+ $team->subscription->stripe_invoice_paid) {
+ // Active subscription exists - we should be cautious
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/app/Console/Commands/CleanupDatabase.php b/app/Console/Commands/CleanupDatabase.php
index 2ccb76529..347ea9419 100644
--- a/app/Console/Commands/CleanupDatabase.php
+++ b/app/Console/Commands/CleanupDatabase.php
@@ -64,13 +64,5 @@ public function handle()
if ($this->option('yes')) {
$scheduled_task_executions->delete();
}
-
- // Cleanup webhooks table
- $webhooks = DB::table('webhooks')->where('created_at', '<', now()->subDays($keep_days));
- $count = $webhooks->count();
- echo "Delete $count entries from webhooks.\n";
- if ($this->option('yes')) {
- $webhooks->delete();
- }
}
}
diff --git a/app/Console/Commands/CleanupNames.php b/app/Console/Commands/CleanupNames.php
new file mode 100644
index 000000000..2992e32b9
--- /dev/null
+++ b/app/Console/Commands/CleanupNames.php
@@ -0,0 +1,248 @@
+ Project::class,
+ 'Environment' => Environment::class,
+ 'Application' => Application::class,
+ 'Service' => Service::class,
+ 'Server' => Server::class,
+ 'Team' => Team::class,
+ 'StandalonePostgresql' => StandalonePostgresql::class,
+ 'StandaloneMysql' => StandaloneMysql::class,
+ 'StandaloneRedis' => StandaloneRedis::class,
+ 'StandaloneMongodb' => StandaloneMongodb::class,
+ 'StandaloneMariadb' => StandaloneMariadb::class,
+ 'StandaloneKeydb' => StandaloneKeydb::class,
+ 'StandaloneDragonfly' => StandaloneDragonfly::class,
+ 'StandaloneClickhouse' => StandaloneClickhouse::class,
+ 'S3Storage' => S3Storage::class,
+ 'Tag' => Tag::class,
+ 'PrivateKey' => PrivateKey::class,
+ 'ScheduledTask' => ScheduledTask::class,
+ ];
+
+ protected array $changes = [];
+
+ protected int $totalProcessed = 0;
+
+ protected int $totalCleaned = 0;
+
+ public function handle(): int
+ {
+ $this->info('🔍 Scanning for invalid characters in name fields...');
+
+ if ($this->option('backup') && ! $this->option('dry-run')) {
+ $this->createBackup();
+ }
+
+ $modelFilter = $this->option('model');
+ $modelsToProcess = $modelFilter
+ ? [$modelFilter => $this->modelsToClean[$modelFilter] ?? null]
+ : $this->modelsToClean;
+
+ if ($modelFilter && ! isset($this->modelsToClean[$modelFilter])) {
+ $this->error("❌ Unknown model: {$modelFilter}");
+ $this->info('Available models: '.implode(', ', array_keys($this->modelsToClean)));
+
+ return self::FAILURE;
+ }
+
+ foreach ($modelsToProcess as $modelName => $modelClass) {
+ if (! $modelClass) {
+ continue;
+ }
+ $this->processModel($modelName, $modelClass);
+ }
+
+ $this->displaySummary();
+
+ if (! $this->option('dry-run') && $this->totalCleaned > 0) {
+ $this->logChanges();
+ }
+
+ return self::SUCCESS;
+ }
+
+ protected function processModel(string $modelName, string $modelClass): void
+ {
+ $this->info("\n📋 Processing {$modelName}...");
+
+ try {
+ $records = $modelClass::all(['id', 'name']);
+ $cleaned = 0;
+
+ foreach ($records as $record) {
+ $this->totalProcessed++;
+
+ $originalName = $record->name;
+ $sanitizedName = $this->sanitizeName($originalName);
+
+ if ($sanitizedName !== $originalName) {
+ $this->changes[] = [
+ 'model' => $modelName,
+ 'id' => $record->id,
+ 'original' => $originalName,
+ 'sanitized' => $sanitizedName,
+ 'timestamp' => now(),
+ ];
+
+ if (! $this->option('dry-run')) {
+ // Update without triggering events/mutators to avoid conflicts
+ $modelClass::where('id', $record->id)->update(['name' => $sanitizedName]);
+ }
+
+ $cleaned++;
+ $this->totalCleaned++;
+
+ $this->warn(" 🧹 {$modelName} #{$record->id}:");
+ $this->line(' From: '.$this->truncate($originalName, 80));
+ $this->line(' To: '.$this->truncate($sanitizedName, 80));
+ }
+ }
+
+ if ($cleaned > 0) {
+ $action = $this->option('dry-run') ? 'would be sanitized' : 'sanitized';
+ $this->info(" ✅ {$cleaned}/{$records->count()} records {$action}");
+ } else {
+ $this->info(' ✨ No invalid characters found');
+ }
+
+ } catch (\Exception $e) {
+ $this->error(" ❌ Error processing {$modelName}: ".$e->getMessage());
+ }
+ }
+
+ protected function sanitizeName(string $name): string
+ {
+ // Remove all characters that don't match the allowed pattern
+ // Use the shared ValidationPatterns to ensure consistency
+ $allowedPattern = str_replace(['/', '^', '$'], '', ValidationPatterns::NAME_PATTERN);
+ $sanitized = preg_replace('/[^'.$allowedPattern.']+/', '', $name);
+
+ // Clean up excessive whitespace but preserve other allowed characters
+ $sanitized = preg_replace('/\s+/', ' ', $sanitized);
+ $sanitized = trim($sanitized);
+
+ // If result is empty, provide a default name
+ if (empty($sanitized)) {
+ $sanitized = 'sanitized-item';
+ }
+
+ return $sanitized;
+ }
+
+ protected function displaySummary(): void
+ {
+ $this->info("\n".str_repeat('=', 60));
+ $this->info('📊 CLEANUP SUMMARY');
+ $this->info(str_repeat('=', 60));
+
+ $this->line("Records processed: {$this->totalProcessed}");
+ $this->line("Records with invalid characters: {$this->totalCleaned}");
+
+ if ($this->option('dry-run')) {
+ $this->warn("\n🔍 DRY RUN - No changes were made to the database");
+ $this->info('Run without --dry-run to apply these changes');
+ } else {
+ if ($this->totalCleaned > 0) {
+ $this->info("\n✅ Database successfully sanitized!");
+ $this->info('Changes logged to storage/logs/name-cleanup.log');
+ } else {
+ $this->info("\n✨ No cleanup needed - all names are valid!");
+ }
+ }
+ }
+
+ protected function logChanges(): void
+ {
+ $logFile = storage_path('logs/name-cleanup.log');
+ $logData = [
+ 'timestamp' => now()->toISOString(),
+ 'total_processed' => $this->totalProcessed,
+ 'total_cleaned' => $this->totalCleaned,
+ 'changes' => $this->changes,
+ ];
+
+ file_put_contents($logFile, json_encode($logData, JSON_PRETTY_PRINT)."\n", FILE_APPEND);
+
+ Log::info('Name Sanitization completed', [
+ 'total_processed' => $this->totalProcessed,
+ 'total_sanitized' => $this->totalCleaned,
+ 'changes_count' => count($this->changes),
+ ]);
+ }
+
+ protected function createBackup(): void
+ {
+ $this->info('💾 Creating database backup...');
+
+ try {
+ $backupFile = storage_path('backups/name-cleanup-backup-'.now()->format('Y-m-d-H-i-s').'.sql');
+
+ // Ensure backup directory exists
+ if (! file_exists(dirname($backupFile))) {
+ mkdir(dirname($backupFile), 0755, true);
+ }
+
+ $dbConfig = config('database.connections.'.config('database.default'));
+ $command = sprintf(
+ 'pg_dump -h %s -p %s -U %s -d %s > %s',
+ $dbConfig['host'],
+ $dbConfig['port'],
+ $dbConfig['username'],
+ $dbConfig['database'],
+ $backupFile
+ );
+
+ exec($command, $output, $returnCode);
+
+ if ($returnCode === 0) {
+ $this->info("✅ Backup created: {$backupFile}");
+ } else {
+ $this->warn('⚠️ Backup creation may have failed. Proceeding anyway...');
+ }
+ } catch (\Exception $e) {
+ $this->warn('⚠️ Could not create backup: '.$e->getMessage());
+ $this->warn('Proceeding without backup...');
+ }
+ }
+
+ protected function truncate(string $text, int $length): string
+ {
+ return strlen($text) > $length ? substr($text, 0, $length).'...' : $text;
+ }
+}
diff --git a/app/Console/Commands/CleanupStuckedResources.php b/app/Console/Commands/CleanupStuckedResources.php
index 81824675b..ce2d6d598 100644
--- a/app/Console/Commands/CleanupStuckedResources.php
+++ b/app/Console/Commands/CleanupStuckedResources.php
@@ -3,6 +3,7 @@
namespace App\Console\Commands;
use App\Jobs\CleanupHelperContainersJob;
+use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
@@ -72,7 +73,7 @@ private function cleanup_stucked_resources()
$applications = Application::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($applications as $application) {
echo "Deleting stuck application: {$application->name}\n";
- $application->forceDelete();
+ DeleteResourceJob::dispatch($application);
}
} catch (\Throwable $e) {
echo "Error in cleaning stuck application: {$e->getMessage()}\n";
@@ -82,26 +83,35 @@ private function cleanup_stucked_resources()
foreach ($applicationsPreviews as $applicationPreview) {
if (! data_get($applicationPreview, 'application')) {
echo "Deleting stuck application preview: {$applicationPreview->uuid}\n";
- $applicationPreview->delete();
+ DeleteResourceJob::dispatch($applicationPreview);
}
}
} catch (\Throwable $e) {
echo "Error in cleaning stuck application: {$e->getMessage()}\n";
}
+ try {
+ $applicationsPreviews = ApplicationPreview::withTrashed()->whereNotNull('deleted_at')->get();
+ foreach ($applicationsPreviews as $applicationPreview) {
+ echo "Deleting stuck application preview: {$applicationPreview->fqdn}\n";
+ DeleteResourceJob::dispatch($applicationPreview);
+ }
+ } catch (\Throwable $e) {
+ echo "Error in cleaning stuck application: {$e->getMessage()}\n";
+ }
try {
$postgresqls = StandalonePostgresql::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($postgresqls as $postgresql) {
echo "Deleting stuck postgresql: {$postgresql->name}\n";
- $postgresql->forceDelete();
+ DeleteResourceJob::dispatch($postgresql);
}
} catch (\Throwable $e) {
echo "Error in cleaning stuck postgresql: {$e->getMessage()}\n";
}
try {
- $redis = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get();
- foreach ($redis as $redis) {
+ $rediss = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get();
+ foreach ($rediss as $redis) {
echo "Deleting stuck redis: {$redis->name}\n";
- $redis->forceDelete();
+ DeleteResourceJob::dispatch($redis);
}
} catch (\Throwable $e) {
echo "Error in cleaning stuck redis: {$e->getMessage()}\n";
@@ -110,7 +120,7 @@ private function cleanup_stucked_resources()
$keydbs = StandaloneKeydb::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($keydbs as $keydb) {
echo "Deleting stuck keydb: {$keydb->name}\n";
- $keydb->forceDelete();
+ DeleteResourceJob::dispatch($keydb);
}
} catch (\Throwable $e) {
echo "Error in cleaning stuck keydb: {$e->getMessage()}\n";
@@ -119,7 +129,7 @@ private function cleanup_stucked_resources()
$dragonflies = StandaloneDragonfly::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($dragonflies as $dragonfly) {
echo "Deleting stuck dragonfly: {$dragonfly->name}\n";
- $dragonfly->forceDelete();
+ DeleteResourceJob::dispatch($dragonfly);
}
} catch (\Throwable $e) {
echo "Error in cleaning stuck dragonfly: {$e->getMessage()}\n";
@@ -128,7 +138,7 @@ private function cleanup_stucked_resources()
$clickhouses = StandaloneClickhouse::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($clickhouses as $clickhouse) {
echo "Deleting stuck clickhouse: {$clickhouse->name}\n";
- $clickhouse->forceDelete();
+ DeleteResourceJob::dispatch($clickhouse);
}
} catch (\Throwable $e) {
echo "Error in cleaning stuck clickhouse: {$e->getMessage()}\n";
@@ -137,7 +147,7 @@ private function cleanup_stucked_resources()
$mongodbs = StandaloneMongodb::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($mongodbs as $mongodb) {
echo "Deleting stuck mongodb: {$mongodb->name}\n";
- $mongodb->forceDelete();
+ DeleteResourceJob::dispatch($mongodb);
}
} catch (\Throwable $e) {
echo "Error in cleaning stuck mongodb: {$e->getMessage()}\n";
@@ -146,7 +156,7 @@ private function cleanup_stucked_resources()
$mysqls = StandaloneMysql::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($mysqls as $mysql) {
echo "Deleting stuck mysql: {$mysql->name}\n";
- $mysql->forceDelete();
+ DeleteResourceJob::dispatch($mysql);
}
} catch (\Throwable $e) {
echo "Error in cleaning stuck mysql: {$e->getMessage()}\n";
@@ -155,7 +165,7 @@ private function cleanup_stucked_resources()
$mariadbs = StandaloneMariadb::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($mariadbs as $mariadb) {
echo "Deleting stuck mariadb: {$mariadb->name}\n";
- $mariadb->forceDelete();
+ DeleteResourceJob::dispatch($mariadb);
}
} catch (\Throwable $e) {
echo "Error in cleaning stuck mariadb: {$e->getMessage()}\n";
@@ -164,7 +174,7 @@ private function cleanup_stucked_resources()
$services = Service::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($services as $service) {
echo "Deleting stuck service: {$service->name}\n";
- $service->forceDelete();
+ DeleteResourceJob::dispatch($service);
}
} catch (\Throwable $e) {
echo "Error in cleaning stuck service: {$e->getMessage()}\n";
@@ -217,19 +227,19 @@ private function cleanup_stucked_resources()
foreach ($applications as $application) {
if (! data_get($application, 'environment')) {
echo 'Application without environment: '.$application->name.'\n';
- $application->forceDelete();
+ DeleteResourceJob::dispatch($application);
continue;
}
if (! $application->destination()) {
echo 'Application without destination: '.$application->name.'\n';
- $application->forceDelete();
+ DeleteResourceJob::dispatch($application);
continue;
}
if (! data_get($application, 'destination.server')) {
echo 'Application without server: '.$application->name.'\n';
- $application->forceDelete();
+ DeleteResourceJob::dispatch($application);
continue;
}
@@ -242,19 +252,19 @@ private function cleanup_stucked_resources()
foreach ($postgresqls as $postgresql) {
if (! data_get($postgresql, 'environment')) {
echo 'Postgresql without environment: '.$postgresql->name.'\n';
- $postgresql->forceDelete();
+ DeleteResourceJob::dispatch($postgresql);
continue;
}
if (! $postgresql->destination()) {
echo 'Postgresql without destination: '.$postgresql->name.'\n';
- $postgresql->forceDelete();
+ DeleteResourceJob::dispatch($postgresql);
continue;
}
if (! data_get($postgresql, 'destination.server')) {
echo 'Postgresql without server: '.$postgresql->name.'\n';
- $postgresql->forceDelete();
+ DeleteResourceJob::dispatch($postgresql);
continue;
}
@@ -267,19 +277,19 @@ private function cleanup_stucked_resources()
foreach ($redis as $redis) {
if (! data_get($redis, 'environment')) {
echo 'Redis without environment: '.$redis->name.'\n';
- $redis->forceDelete();
+ DeleteResourceJob::dispatch($redis);
continue;
}
if (! $redis->destination()) {
echo 'Redis without destination: '.$redis->name.'\n';
- $redis->forceDelete();
+ DeleteResourceJob::dispatch($redis);
continue;
}
if (! data_get($redis, 'destination.server')) {
echo 'Redis without server: '.$redis->name.'\n';
- $redis->forceDelete();
+ DeleteResourceJob::dispatch($redis);
continue;
}
@@ -293,19 +303,19 @@ private function cleanup_stucked_resources()
foreach ($mongodbs as $mongodb) {
if (! data_get($mongodb, 'environment')) {
echo 'Mongodb without environment: '.$mongodb->name.'\n';
- $mongodb->forceDelete();
+ DeleteResourceJob::dispatch($mongodb);
continue;
}
if (! $mongodb->destination()) {
echo 'Mongodb without destination: '.$mongodb->name.'\n';
- $mongodb->forceDelete();
+ DeleteResourceJob::dispatch($mongodb);
continue;
}
if (! data_get($mongodb, 'destination.server')) {
echo 'Mongodb without server: '.$mongodb->name.'\n';
- $mongodb->forceDelete();
+ DeleteResourceJob::dispatch($mongodb);
continue;
}
@@ -319,19 +329,19 @@ private function cleanup_stucked_resources()
foreach ($mysqls as $mysql) {
if (! data_get($mysql, 'environment')) {
echo 'Mysql without environment: '.$mysql->name.'\n';
- $mysql->forceDelete();
+ DeleteResourceJob::dispatch($mysql);
continue;
}
if (! $mysql->destination()) {
echo 'Mysql without destination: '.$mysql->name.'\n';
- $mysql->forceDelete();
+ DeleteResourceJob::dispatch($mysql);
continue;
}
if (! data_get($mysql, 'destination.server')) {
echo 'Mysql without server: '.$mysql->name.'\n';
- $mysql->forceDelete();
+ DeleteResourceJob::dispatch($mysql);
continue;
}
@@ -345,19 +355,19 @@ private function cleanup_stucked_resources()
foreach ($mariadbs as $mariadb) {
if (! data_get($mariadb, 'environment')) {
echo 'Mariadb without environment: '.$mariadb->name.'\n';
- $mariadb->forceDelete();
+ DeleteResourceJob::dispatch($mariadb);
continue;
}
if (! $mariadb->destination()) {
echo 'Mariadb without destination: '.$mariadb->name.'\n';
- $mariadb->forceDelete();
+ DeleteResourceJob::dispatch($mariadb);
continue;
}
if (! data_get($mariadb, 'destination.server')) {
echo 'Mariadb without server: '.$mariadb->name.'\n';
- $mariadb->forceDelete();
+ DeleteResourceJob::dispatch($mariadb);
continue;
}
@@ -371,19 +381,19 @@ private function cleanup_stucked_resources()
foreach ($services as $service) {
if (! data_get($service, 'environment')) {
echo 'Service without environment: '.$service->name.'\n';
- $service->forceDelete();
+ DeleteResourceJob::dispatch($service);
continue;
}
if (! $service->destination()) {
echo 'Service without destination: '.$service->name.'\n';
- $service->forceDelete();
+ DeleteResourceJob::dispatch($service);
continue;
}
if (! data_get($service, 'server')) {
echo 'Service without server: '.$service->name.'\n';
- $service->forceDelete();
+ DeleteResourceJob::dispatch($service);
continue;
}
@@ -396,7 +406,7 @@ private function cleanup_stucked_resources()
foreach ($serviceApplications as $service) {
if (! data_get($service, 'service')) {
echo 'ServiceApplication without service: '.$service->name.'\n';
- $service->forceDelete();
+ DeleteResourceJob::dispatch($service);
continue;
}
@@ -409,7 +419,7 @@ private function cleanup_stucked_resources()
foreach ($serviceDatabases as $service) {
if (! data_get($service, 'service')) {
echo 'ServiceDatabase without service: '.$service->name.'\n';
- $service->forceDelete();
+ DeleteResourceJob::dispatch($service);
continue;
}
diff --git a/app/Console/Commands/CloudDeleteUser.php b/app/Console/Commands/CloudDeleteUser.php
new file mode 100644
index 000000000..6928eb97b
--- /dev/null
+++ b/app/Console/Commands/CloudDeleteUser.php
@@ -0,0 +1,722 @@
+error('This command is only available on cloud instances.');
+
+ return 1;
+ }
+
+ $email = $this->argument('email');
+ $this->isDryRun = $this->option('dry-run');
+ $this->skipStripe = $this->option('skip-stripe');
+ $this->skipResources = $this->option('skip-resources');
+
+ if ($this->isDryRun) {
+ $this->info('🔍 DRY RUN MODE - No data will be deleted');
+ $this->newLine();
+ }
+
+ try {
+ $this->user = User::whereEmail($email)->firstOrFail();
+ } catch (\Exception $e) {
+ $this->error("User with email '{$email}' not found.");
+
+ return 1;
+ }
+
+ $this->logAction("Starting user deletion process for: {$email}");
+
+ // Phase 1: Show User Overview (outside transaction)
+ if (! $this->showUserOverview()) {
+ $this->info('User deletion cancelled.');
+
+ return 0;
+ }
+
+ // If not dry run, wrap everything in a transaction
+ if (! $this->isDryRun) {
+ try {
+ DB::beginTransaction();
+
+ // Phase 2: Delete Resources
+ if (! $this->skipResources) {
+ if (! $this->deleteResources()) {
+ DB::rollBack();
+ $this->error('User deletion failed at resource deletion phase. All changes rolled back.');
+
+ return 1;
+ }
+ }
+
+ // Phase 3: Delete Servers
+ if (! $this->deleteServers()) {
+ DB::rollBack();
+ $this->error('User deletion failed at server deletion phase. All changes rolled back.');
+
+ return 1;
+ }
+
+ // Phase 4: Handle Teams
+ if (! $this->handleTeams()) {
+ DB::rollBack();
+ $this->error('User deletion failed at team handling phase. All changes rolled back.');
+
+ return 1;
+ }
+
+ // Phase 5: Cancel Stripe Subscriptions
+ if (! $this->skipStripe && isCloud()) {
+ if (! $this->cancelStripeSubscriptions()) {
+ DB::rollBack();
+ $this->error('User deletion failed at Stripe cancellation phase. All changes rolled back.');
+
+ return 1;
+ }
+ }
+
+ // Phase 6: Delete User Profile
+ if (! $this->deleteUserProfile()) {
+ DB::rollBack();
+ $this->error('User deletion failed at final phase. All changes rolled back.');
+
+ return 1;
+ }
+
+ // Commit the transaction
+ DB::commit();
+
+ $this->newLine();
+ $this->info('✅ User deletion completed successfully!');
+ $this->logAction("User deletion completed for: {$email}");
+
+ } catch (\Exception $e) {
+ DB::rollBack();
+ $this->error('An error occurred during user deletion: '.$e->getMessage());
+ $this->logAction("User deletion failed for {$email}: ".$e->getMessage());
+
+ return 1;
+ }
+ } else {
+ // Dry run mode - just run through the phases without transaction
+ // Phase 2: Delete Resources
+ if (! $this->skipResources) {
+ if (! $this->deleteResources()) {
+ $this->info('User deletion would be cancelled at resource deletion phase.');
+
+ return 0;
+ }
+ }
+
+ // Phase 3: Delete Servers
+ if (! $this->deleteServers()) {
+ $this->info('User deletion would be cancelled at server deletion phase.');
+
+ return 0;
+ }
+
+ // Phase 4: Handle Teams
+ if (! $this->handleTeams()) {
+ $this->info('User deletion would be cancelled at team handling phase.');
+
+ return 0;
+ }
+
+ // Phase 5: Cancel Stripe Subscriptions
+ if (! $this->skipStripe && isCloud()) {
+ if (! $this->cancelStripeSubscriptions()) {
+ $this->info('User deletion would be cancelled at Stripe cancellation phase.');
+
+ return 0;
+ }
+ }
+
+ // Phase 6: Delete User Profile
+ if (! $this->deleteUserProfile()) {
+ $this->info('User deletion would be cancelled at final phase.');
+
+ return 0;
+ }
+
+ $this->newLine();
+ $this->info('✅ DRY RUN completed successfully! No data was deleted.');
+ }
+
+ return 0;
+ }
+
+ private function showUserOverview(): bool
+ {
+ $this->info('═══════════════════════════════════════');
+ $this->info('PHASE 1: USER OVERVIEW');
+ $this->info('═══════════════════════════════════════');
+ $this->newLine();
+
+ $teams = $this->user->teams;
+ $ownedTeams = $teams->filter(fn ($team) => $team->pivot->role === 'owner');
+ $memberTeams = $teams->filter(fn ($team) => $team->pivot->role !== 'owner');
+
+ // Collect all servers from all teams
+ $allServers = collect();
+ $allApplications = collect();
+ $allDatabases = collect();
+ $allServices = collect();
+ $activeSubscriptions = collect();
+
+ foreach ($teams as $team) {
+ $servers = $team->servers;
+ $allServers = $allServers->merge($servers);
+
+ foreach ($servers as $server) {
+ $resources = $server->definedResources();
+ foreach ($resources as $resource) {
+ if ($resource instanceof \App\Models\Application) {
+ $allApplications->push($resource);
+ } elseif ($resource instanceof \App\Models\Service) {
+ $allServices->push($resource);
+ } else {
+ $allDatabases->push($resource);
+ }
+ }
+ }
+
+ if ($team->subscription && $team->subscription->stripe_subscription_id) {
+ $activeSubscriptions->push($team->subscription);
+ }
+ }
+
+ $this->table(
+ ['Property', 'Value'],
+ [
+ ['User', $this->user->email],
+ ['User ID', $this->user->id],
+ ['Created', $this->user->created_at->format('Y-m-d H:i:s')],
+ ['Last Login', $this->user->updated_at->format('Y-m-d H:i:s')],
+ ['Teams (Total)', $teams->count()],
+ ['Teams (Owner)', $ownedTeams->count()],
+ ['Teams (Member)', $memberTeams->count()],
+ ['Servers', $allServers->unique('id')->count()],
+ ['Applications', $allApplications->count()],
+ ['Databases', $allDatabases->count()],
+ ['Services', $allServices->count()],
+ ['Active Stripe Subscriptions', $activeSubscriptions->count()],
+ ]
+ );
+
+ $this->newLine();
+
+ $this->warn('⚠️ WARNING: This will permanently delete the user and all associated data!');
+ $this->newLine();
+
+ if (! $this->confirm('Do you want to continue with the deletion process?', false)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private function deleteResources(): bool
+ {
+ $this->newLine();
+ $this->info('═══════════════════════════════════════');
+ $this->info('PHASE 2: DELETE RESOURCES');
+ $this->info('═══════════════════════════════════════');
+ $this->newLine();
+
+ $action = new DeleteUserResources($this->user, $this->isDryRun);
+ $resources = $action->getResourcesPreview();
+
+ if ($resources['applications']->isEmpty() &&
+ $resources['databases']->isEmpty() &&
+ $resources['services']->isEmpty()) {
+ $this->info('No resources to delete.');
+
+ return true;
+ }
+
+ $this->info('Resources to be deleted:');
+ $this->newLine();
+
+ if ($resources['applications']->isNotEmpty()) {
+ $this->warn("Applications to be deleted ({$resources['applications']->count()}):");
+ $this->table(
+ ['Name', 'UUID', 'Server', 'Status'],
+ $resources['applications']->map(function ($app) {
+ return [
+ $app->name,
+ $app->uuid,
+ $app->destination->server->name,
+ $app->status ?? 'unknown',
+ ];
+ })->toArray()
+ );
+ $this->newLine();
+ }
+
+ if ($resources['databases']->isNotEmpty()) {
+ $this->warn("Databases to be deleted ({$resources['databases']->count()}):");
+ $this->table(
+ ['Name', 'Type', 'UUID', 'Server'],
+ $resources['databases']->map(function ($db) {
+ return [
+ $db->name,
+ class_basename($db),
+ $db->uuid,
+ $db->destination->server->name,
+ ];
+ })->toArray()
+ );
+ $this->newLine();
+ }
+
+ if ($resources['services']->isNotEmpty()) {
+ $this->warn("Services to be deleted ({$resources['services']->count()}):");
+ $this->table(
+ ['Name', 'UUID', 'Server'],
+ $resources['services']->map(function ($service) {
+ return [
+ $service->name,
+ $service->uuid,
+ $service->server->name,
+ ];
+ })->toArray()
+ );
+ $this->newLine();
+ }
+
+ $this->error('⚠️ THIS ACTION CANNOT BE UNDONE!');
+ if (! $this->confirm('Are you sure you want to delete all these resources?', false)) {
+ return false;
+ }
+
+ if (! $this->isDryRun) {
+ $this->info('Deleting resources...');
+ $result = $action->execute();
+ $this->info("Deleted: {$result['applications']} applications, {$result['databases']} databases, {$result['services']} services");
+ $this->logAction("Deleted resources for user {$this->user->email}: {$result['applications']} apps, {$result['databases']} databases, {$result['services']} services");
+ }
+
+ return true;
+ }
+
+ private function deleteServers(): bool
+ {
+ $this->newLine();
+ $this->info('═══════════════════════════════════════');
+ $this->info('PHASE 3: DELETE SERVERS');
+ $this->info('═══════════════════════════════════════');
+ $this->newLine();
+
+ $action = new DeleteUserServers($this->user, $this->isDryRun);
+ $servers = $action->getServersPreview();
+
+ if ($servers->isEmpty()) {
+ $this->info('No servers to delete.');
+
+ return true;
+ }
+
+ $this->warn("Servers to be deleted ({$servers->count()}):");
+ $this->table(
+ ['ID', 'Name', 'IP', 'Description', 'Resources Count'],
+ $servers->map(function ($server) {
+ $resourceCount = $server->definedResources()->count();
+
+ return [
+ $server->id,
+ $server->name,
+ $server->ip,
+ $server->description ?? '-',
+ $resourceCount,
+ ];
+ })->toArray()
+ );
+ $this->newLine();
+
+ $this->error('⚠️ WARNING: Deleting servers will remove all server configurations!');
+ if (! $this->confirm('Are you sure you want to delete all these servers?', false)) {
+ return false;
+ }
+
+ if (! $this->isDryRun) {
+ $this->info('Deleting servers...');
+ $result = $action->execute();
+ $this->info("Deleted {$result['servers']} servers");
+ $this->logAction("Deleted {$result['servers']} servers for user {$this->user->email}");
+ }
+
+ return true;
+ }
+
+ private function handleTeams(): bool
+ {
+ $this->newLine();
+ $this->info('═══════════════════════════════════════');
+ $this->info('PHASE 4: HANDLE TEAMS');
+ $this->info('═══════════════════════════════════════');
+ $this->newLine();
+
+ $action = new DeleteUserTeams($this->user, $this->isDryRun);
+ $preview = $action->getTeamsPreview();
+
+ // Check for edge cases first - EXIT IMMEDIATELY if found
+ if ($preview['edge_cases']->isNotEmpty()) {
+ $this->error('═══════════════════════════════════════');
+ $this->error('⚠️ EDGE CASES DETECTED - CANNOT PROCEED');
+ $this->error('═══════════════════════════════════════');
+ $this->newLine();
+
+ foreach ($preview['edge_cases'] as $edgeCase) {
+ $team = $edgeCase['team'];
+ $reason = $edgeCase['reason'];
+ $this->error("Team: {$team->name} (ID: {$team->id})");
+ $this->error("Issue: {$reason}");
+
+ // Show team members for context
+ $this->info('Current members:');
+ foreach ($team->members as $member) {
+ $role = $member->pivot->role;
+ $this->line(" - {$member->name} ({$member->email}) - Role: {$role}");
+ }
+
+ // Check for active resources
+ $resourceCount = 0;
+ foreach ($team->servers as $server) {
+ $resources = $server->definedResources();
+ $resourceCount += $resources->count();
+ }
+
+ if ($resourceCount > 0) {
+ $this->warn(" ⚠️ This team has {$resourceCount} active resources!");
+ }
+
+ // Show subscription details if relevant
+ if ($team->subscription && $team->subscription->stripe_subscription_id) {
+ $this->warn(' ⚠️ Active Stripe subscription details:');
+ $this->warn(" Subscription ID: {$team->subscription->stripe_subscription_id}");
+ $this->warn(" Customer ID: {$team->subscription->stripe_customer_id}");
+
+ // Show other owners who could potentially take over
+ $otherOwners = $team->members
+ ->where('id', '!=', $this->user->id)
+ ->filter(function ($member) {
+ return $member->pivot->role === 'owner';
+ });
+
+ if ($otherOwners->isNotEmpty()) {
+ $this->info(' Other owners who could take over billing:');
+ foreach ($otherOwners as $owner) {
+ $this->line(" - {$owner->name} ({$owner->email})");
+ }
+ }
+ }
+
+ $this->newLine();
+ }
+
+ $this->error('Please resolve these issues manually before retrying:');
+
+ // Check if any edge case involves subscription payment issues
+ $hasSubscriptionIssue = $preview['edge_cases']->contains(function ($edgeCase) {
+ return str_contains($edgeCase['reason'], 'Stripe subscription');
+ });
+
+ if ($hasSubscriptionIssue) {
+ $this->info('For teams with subscription payment issues:');
+ $this->info('1. Cancel the subscription through Stripe dashboard, OR');
+ $this->info('2. Transfer the subscription to another owner\'s payment method, OR');
+ $this->info('3. Have the other owner create a new subscription after cancelling this one');
+ $this->newLine();
+ }
+
+ $hasNoOwnerReplacement = $preview['edge_cases']->contains(function ($edgeCase) {
+ return str_contains($edgeCase['reason'], 'No suitable owner replacement');
+ });
+
+ if ($hasNoOwnerReplacement) {
+ $this->info('For teams with no suitable owner replacement:');
+ $this->info('1. Assign an admin role to a trusted member, OR');
+ $this->info('2. Transfer team resources to another team, OR');
+ $this->info('3. Delete the team manually if no longer needed');
+ $this->newLine();
+ }
+
+ $this->error('USER DELETION ABORTED DUE TO EDGE CASES');
+ $this->logAction("User deletion aborted for {$this->user->email}: Edge cases in team handling");
+
+ // Exit immediately - don't proceed with deletion
+ if (! $this->isDryRun) {
+ DB::rollBack();
+ }
+ exit(1);
+ }
+
+ if ($preview['to_delete']->isEmpty() &&
+ $preview['to_transfer']->isEmpty() &&
+ $preview['to_leave']->isEmpty()) {
+ $this->info('No team changes needed.');
+
+ return true;
+ }
+
+ if ($preview['to_delete']->isNotEmpty()) {
+ $this->warn('Teams to be DELETED (user is the only member):');
+ $this->table(
+ ['ID', 'Name', 'Resources', 'Subscription'],
+ $preview['to_delete']->map(function ($team) {
+ $resourceCount = 0;
+ foreach ($team->servers as $server) {
+ $resourceCount += $server->definedResources()->count();
+ }
+ $hasSubscription = $team->subscription && $team->subscription->stripe_subscription_id
+ ? '⚠️ YES - '.$team->subscription->stripe_subscription_id
+ : 'No';
+
+ return [
+ $team->id,
+ $team->name,
+ $resourceCount,
+ $hasSubscription,
+ ];
+ })->toArray()
+ );
+ $this->newLine();
+ }
+
+ if ($preview['to_transfer']->isNotEmpty()) {
+ $this->warn('Teams where ownership will be TRANSFERRED:');
+ $this->table(
+ ['Team ID', 'Team Name', 'New Owner', 'New Owner Email'],
+ $preview['to_transfer']->map(function ($item) {
+ return [
+ $item['team']->id,
+ $item['team']->name,
+ $item['new_owner']->name,
+ $item['new_owner']->email,
+ ];
+ })->toArray()
+ );
+ $this->newLine();
+ }
+
+ if ($preview['to_leave']->isNotEmpty()) {
+ $this->warn('Teams where user will be REMOVED (other owners/admins exist):');
+ $userId = $this->user->id;
+ $this->table(
+ ['ID', 'Name', 'User Role', 'Other Members'],
+ $preview['to_leave']->map(function ($team) use ($userId) {
+ $userRole = $team->members->where('id', $userId)->first()->pivot->role;
+ $otherMembers = $team->members->count() - 1;
+
+ return [
+ $team->id,
+ $team->name,
+ $userRole,
+ $otherMembers,
+ ];
+ })->toArray()
+ );
+ $this->newLine();
+ }
+
+ $this->error('⚠️ WARNING: Team changes affect access control and ownership!');
+ if (! $this->confirm('Are you sure you want to proceed with these team changes?', false)) {
+ return false;
+ }
+
+ if (! $this->isDryRun) {
+ $this->info('Processing team changes...');
+ $result = $action->execute();
+ $this->info("Teams deleted: {$result['deleted']}, ownership transferred: {$result['transferred']}, left: {$result['left']}");
+ $this->logAction("Team changes for user {$this->user->email}: deleted {$result['deleted']}, transferred {$result['transferred']}, left {$result['left']}");
+ }
+
+ return true;
+ }
+
+ private function cancelStripeSubscriptions(): bool
+ {
+ $this->newLine();
+ $this->info('═══════════════════════════════════════');
+ $this->info('PHASE 5: CANCEL STRIPE SUBSCRIPTIONS');
+ $this->info('═══════════════════════════════════════');
+ $this->newLine();
+
+ $action = new CancelSubscription($this->user, $this->isDryRun);
+ $subscriptions = $action->getSubscriptionsPreview();
+
+ if ($subscriptions->isEmpty()) {
+ $this->info('No Stripe subscriptions to cancel.');
+
+ return true;
+ }
+
+ $this->info('Stripe subscriptions to cancel:');
+ $this->newLine();
+
+ $totalMonthlyValue = 0;
+ foreach ($subscriptions as $subscription) {
+ $team = $subscription->team;
+ $planId = $subscription->stripe_plan_id;
+
+ // Try to get the price from config
+ $monthlyValue = $this->getSubscriptionMonthlyValue($planId);
+ $totalMonthlyValue += $monthlyValue;
+
+ $this->line(" - {$subscription->stripe_subscription_id} (Team: {$team->name})");
+ if ($monthlyValue > 0) {
+ $this->line(" Monthly value: \${$monthlyValue}");
+ }
+ if ($subscription->stripe_cancel_at_period_end) {
+ $this->line(' ⚠️ Already set to cancel at period end');
+ }
+ }
+
+ if ($totalMonthlyValue > 0) {
+ $this->newLine();
+ $this->warn("Total monthly value: \${$totalMonthlyValue}");
+ }
+ $this->newLine();
+
+ $this->error('⚠️ WARNING: Subscriptions will be cancelled IMMEDIATELY (not at period end)!');
+ if (! $this->confirm('Are you sure you want to cancel all these subscriptions immediately?', false)) {
+ return false;
+ }
+
+ if (! $this->isDryRun) {
+ $this->info('Cancelling subscriptions...');
+ $result = $action->execute();
+ $this->info("Cancelled {$result['cancelled']} subscriptions, {$result['failed']} failed");
+ if ($result['failed'] > 0 && ! empty($result['errors'])) {
+ $this->error('Failed subscriptions:');
+ foreach ($result['errors'] as $error) {
+ $this->error(" - {$error}");
+ }
+ }
+ $this->logAction("Cancelled {$result['cancelled']} Stripe subscriptions for user {$this->user->email}");
+ }
+
+ return true;
+ }
+
+ private function deleteUserProfile(): bool
+ {
+ $this->newLine();
+ $this->info('═══════════════════════════════════════');
+ $this->info('PHASE 6: DELETE USER PROFILE');
+ $this->info('═══════════════════════════════════════');
+ $this->newLine();
+
+ $this->warn('⚠️ FINAL STEP - This action is IRREVERSIBLE!');
+ $this->newLine();
+
+ $this->info('User profile to be deleted:');
+ $this->table(
+ ['Property', 'Value'],
+ [
+ ['Email', $this->user->email],
+ ['Name', $this->user->name],
+ ['User ID', $this->user->id],
+ ['Created', $this->user->created_at->format('Y-m-d H:i:s')],
+ ['Email Verified', $this->user->email_verified_at ? 'Yes' : 'No'],
+ ['2FA Enabled', $this->user->two_factor_confirmed_at ? 'Yes' : 'No'],
+ ]
+ );
+
+ $this->newLine();
+
+ $this->warn("Type 'DELETE {$this->user->email}' to confirm final deletion:");
+ $confirmation = $this->ask('Confirmation');
+
+ if ($confirmation !== "DELETE {$this->user->email}") {
+ $this->error('Confirmation text does not match. Deletion cancelled.');
+
+ return false;
+ }
+
+ if (! $this->isDryRun) {
+ $this->info('Deleting user profile...');
+
+ try {
+ $this->user->delete();
+ $this->info('User profile deleted successfully.');
+ $this->logAction("User profile deleted: {$this->user->email}");
+ } catch (\Exception $e) {
+ $this->error('Failed to delete user profile: '.$e->getMessage());
+ $this->logAction("Failed to delete user profile {$this->user->email}: ".$e->getMessage());
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private function getSubscriptionMonthlyValue(string $planId): int
+ {
+ // Map plan IDs to monthly values based on config
+ $subscriptionConfigs = config('subscription');
+
+ foreach ($subscriptionConfigs as $key => $value) {
+ if ($value === $planId && str_contains($key, 'stripe_price_id_')) {
+ // Extract price from key pattern: stripe_price_id_basic_monthly -> basic
+ $planType = str($key)->after('stripe_price_id_')->before('_')->toString();
+
+ // Map to known prices (you may need to adjust these based on your actual pricing)
+ return match ($planType) {
+ 'basic' => 29,
+ 'pro' => 49,
+ 'ultimate' => 99,
+ default => 0
+ };
+ }
+ }
+
+ return 0;
+ }
+
+ private function logAction(string $message): void
+ {
+ $logMessage = "[CloudDeleteUser] {$message}";
+
+ if ($this->isDryRun) {
+ $logMessage = "[DRY RUN] {$logMessage}";
+ }
+
+ Log::channel('single')->info($logMessage);
+
+ // Also log to a dedicated user deletion log file
+ $logFile = storage_path('logs/user-deletions.log');
+ $timestamp = now()->format('Y-m-d H:i:s');
+ file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX);
+ }
+}
diff --git a/app/Console/Commands/Dev.php b/app/Console/Commands/Dev.php
index a4cfde6f8..8f26d78ff 100644
--- a/app/Console/Commands/Dev.php
+++ b/app/Console/Commands/Dev.php
@@ -2,6 +2,7 @@
namespace App\Console\Commands;
+use App\Jobs\CheckHelperImageJob;
use App\Models\InstanceSettings;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
@@ -44,5 +45,6 @@ public function init()
} else {
echo "Instance already initialized.\n";
}
+ CheckHelperImageJob::dispatch();
}
}
diff --git a/app/Console/Commands/Generate/Services.php b/app/Console/Commands/Generate/Services.php
index 577e94ac8..42f9360bb 100644
--- a/app/Console/Commands/Generate/Services.php
+++ b/app/Console/Commands/Generate/Services.php
@@ -16,7 +16,7 @@ class Services extends Command
/**
* {@inheritdoc}
*/
- protected $description = 'Generate service-templates.yaml based on /templates/compose directory';
+ protected $description = 'Generates service-templates json file based on /templates/compose directory';
public function handle(): int
{
@@ -33,7 +33,10 @@ public function handle(): int
];
})->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
- file_put_contents(base_path('templates/service-templates.json'), $serviceTemplatesJson.PHP_EOL);
+ file_put_contents(base_path('templates/'.config('constants.services.file_name')), $serviceTemplatesJson.PHP_EOL);
+
+ // Generate service-templates.json with SERVICE_URL changed to SERVICE_FQDN
+ $this->generateServiceTemplatesWithFqdn();
return self::SUCCESS;
}
@@ -71,6 +74,7 @@ private function processFile(string $file): false|array
'slogan' => $data->get('slogan', str($file)->headline()),
'compose' => $compose,
'tags' => $tags,
+ 'category' => $data->get('category'),
'logo' => $data->get('logo', 'svgs/default.webp'),
'minversion' => $data->get('minversion', '0.0.0'),
];
@@ -86,4 +90,145 @@ private function processFile(string $file): false|array
return $payload;
}
+
+ private function generateServiceTemplatesWithFqdn(): void
+ {
+ $serviceTemplatesWithFqdn = collect(array_merge(
+ glob(base_path('templates/compose/*.yaml')),
+ glob(base_path('templates/compose/*.yml'))
+ ))
+ ->mapWithKeys(function ($file): array {
+ $file = basename($file);
+ $parsed = $this->processFileWithFqdn($file);
+
+ return $parsed === false ? [] : [
+ Arr::pull($parsed, 'name') => $parsed,
+ ];
+ })->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+
+ file_put_contents(base_path('templates/service-templates.json'), $serviceTemplatesWithFqdn.PHP_EOL);
+
+ // Generate service-templates-raw.json with non-base64 encoded compose content
+ // $this->generateServiceTemplatesRaw();
+ }
+
+ private function processFileWithFqdn(string $file): false|array
+ {
+ $content = file_get_contents(base_path("templates/compose/$file"));
+
+ $data = collect(explode(PHP_EOL, $content))->mapWithKeys(function ($line): array {
+ preg_match('/^#(?]*>/', '
', $html); + + // Lists + $html = preg_replace('/
]*>/', '', $html);
+ $html = preg_replace('/]*>/', '', $html);
+
+ // Links - Apply styling to existing markdown links
+ $html = preg_replace('/]*)>/', '', $html);
+
+ // Convert plain URLs to clickable links (that aren't already in tags)
+ $html = preg_replace('/(?)(?"]+)(?![^<]*<\/a>)/', '$1', $html);
+
+ // Strong/bold text
+ $html = preg_replace('/]*>/', '', $html);
+
+ // Emphasis/italic text
+ $html = preg_replace('/]*>/', '', $html);
+
+ return $html;
+ }
+}
diff --git a/app/Services/ConfigurationGenerator.php b/app/Services/ConfigurationGenerator.php
index a7e4b31be..320e3f32a 100644
--- a/app/Services/ConfigurationGenerator.php
+++ b/app/Services/ConfigurationGenerator.php
@@ -129,7 +129,6 @@ protected function getEnvironmentVariables(): array
$variables->push([
'key' => $env->key,
'value' => $env->value,
- 'is_build_time' => $env->is_build_time,
'is_preview' => $env->is_preview,
'is_multiline' => $env->is_multiline,
]);
@@ -145,7 +144,6 @@ protected function getPreviewEnvironmentVariables(): array
$variables->push([
'key' => $env->key,
'value' => $env->value,
- 'is_build_time' => $env->is_build_time,
'is_preview' => $env->is_preview,
'is_multiline' => $env->is_multiline,
]);
diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php
new file mode 100644
index 000000000..965142558
--- /dev/null
+++ b/app/Support/ValidationPatterns.php
@@ -0,0 +1,93 @@
+ 'The name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
+ 'name.min' => 'The name must be at least :min characters.',
+ 'name.max' => 'The name may not be greater than :max characters.',
+ ];
+ }
+
+ /**
+ * Get validation messages for description fields
+ */
+ public static function descriptionMessages(): array
+ {
+ return [
+ 'description.regex' => 'The description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
+ 'description.max' => 'The description may not be greater than :max characters.',
+ ];
+ }
+
+ /**
+ * Get combined validation messages for both name and description fields
+ */
+ public static function combinedMessages(): array
+ {
+ return array_merge(self::nameMessages(), self::descriptionMessages());
+ }
+}
diff --git a/app/Traits/AuthorizesResourceCreation.php b/app/Traits/AuthorizesResourceCreation.php
new file mode 100644
index 000000000..01ae7c8d9
--- /dev/null
+++ b/app/Traits/AuthorizesResourceCreation.php
@@ -0,0 +1,20 @@
+authorize('createAnyResource');
+ }
+}
diff --git a/app/Traits/ClearsGlobalSearchCache.php b/app/Traits/ClearsGlobalSearchCache.php
new file mode 100644
index 000000000..0bcc5d319
--- /dev/null
+++ b/app/Traits/ClearsGlobalSearchCache.php
@@ -0,0 +1,81 @@
+hasSearchableChanges()) {
+ $teamId = $model->getTeamIdForCache();
+ if (filled($teamId)) {
+ GlobalSearch::clearTeamCache($teamId);
+ }
+ }
+ });
+
+ static::created(function ($model) {
+ // Always clear cache when model is created
+ $teamId = $model->getTeamIdForCache();
+ if (filled($teamId)) {
+ GlobalSearch::clearTeamCache($teamId);
+ }
+ });
+
+ static::deleted(function ($model) {
+ // Always clear cache when model is deleted
+ $teamId = $model->getTeamIdForCache();
+ if (filled($teamId)) {
+ GlobalSearch::clearTeamCache($teamId);
+ }
+ });
+ }
+
+ private function hasSearchableChanges(): bool
+ {
+ // Define searchable fields based on model type
+ $searchableFields = ['name', 'description'];
+
+ // Add model-specific searchable fields
+ if ($this instanceof \App\Models\Application) {
+ $searchableFields[] = 'fqdn';
+ $searchableFields[] = 'docker_compose_domains';
+ } elseif ($this instanceof \App\Models\Server) {
+ $searchableFields[] = 'ip';
+ } elseif ($this instanceof \App\Models\Service) {
+ // Services don't have direct fqdn, but name and description are covered
+ }
+ // Database models only have name and description as searchable
+
+ // Check if any searchable field is dirty
+ foreach ($searchableFields as $field) {
+ if ($this->isDirty($field)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function getTeamIdForCache()
+ {
+ // For database models, team is accessed through environment.project.team
+ if (method_exists($this, 'team')) {
+ $team = $this->team();
+ if (filled($team)) {
+ return is_object($team) ? $team->id : null;
+ }
+ }
+
+ // For models with direct team_id property
+ if (property_exists($this, 'team_id') || isset($this->team_id)) {
+ return $this->team_id;
+ }
+
+ return null;
+ }
+}
diff --git a/app/Traits/EnvironmentVariableProtection.php b/app/Traits/EnvironmentVariableProtection.php
index b6b8d2687..ecc484966 100644
--- a/app/Traits/EnvironmentVariableProtection.php
+++ b/app/Traits/EnvironmentVariableProtection.php
@@ -14,7 +14,7 @@ trait EnvironmentVariableProtection
*/
protected function isProtectedEnvironmentVariable(string $key): bool
{
- return str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL');
+ return str($key)->startsWith('SERVICE_FQDN_') || str($key)->startsWith('SERVICE_URL_') || str($key)->startsWith('SERVICE_NAME_');
}
/**
diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php
index a228a5d10..f9df19c16 100644
--- a/app/Traits/ExecuteRemoteCommand.php
+++ b/app/Traits/ExecuteRemoteCommand.php
@@ -11,10 +11,52 @@
trait ExecuteRemoteCommand
{
+ use SshRetryable;
+
public ?string $save = null;
public static int $batch_counter = 0;
+ private function redact_sensitive_info($text)
+ {
+ $text = remove_iip($text);
+
+ if (! isset($this->application)) {
+ return $text;
+ }
+
+ $lockedVars = collect([]);
+
+ if (isset($this->application->environment_variables)) {
+ $lockedVars = $lockedVars->merge(
+ $this->application->environment_variables
+ ->where('is_shown_once', true)
+ ->pluck('real_value', 'key')
+ ->filter()
+ );
+ }
+
+ if (isset($this->pull_request_id) && $this->pull_request_id !== 0 && isset($this->application->environment_variables_preview)) {
+ $lockedVars = $lockedVars->merge(
+ $this->application->environment_variables_preview
+ ->where('is_shown_once', true)
+ ->pluck('real_value', 'key')
+ ->filter()
+ );
+ }
+
+ foreach ($lockedVars as $key => $value) {
+ $escapedValue = preg_quote($value, '/');
+ $text = preg_replace(
+ '/'.$escapedValue.'/',
+ REDACTED,
+ $text
+ );
+ }
+
+ return $text;
+ }
+
public function execute_remote_command(...$commands)
{
static::$batch_counter++;
@@ -43,76 +85,188 @@ public function execute_remote_command(...$commands)
$command = parseLineForSudo($command, $this->server);
}
}
- $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) {
- $output = str($output)->trim();
- if ($output->startsWith('╔')) {
- $output = "\n".$output;
+
+ // Check for cancellation before executing commands
+ if (isset($this->application_deployment_queue)) {
+ $this->application_deployment_queue->refresh();
+ if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
+ throw new \RuntimeException('Deployment cancelled by user', 69420);
}
+ }
- // Sanitize output to ensure valid UTF-8 encoding before JSON encoding
- $sanitized_output = sanitize_utf8_text($output);
-
- $new_log_entry = [
- 'command' => remove_iip($command),
- 'output' => remove_iip($sanitized_output),
- 'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout',
- 'timestamp' => Carbon::now('UTC'),
- 'hidden' => $hidden,
- 'batch' => static::$batch_counter,
- ];
- if (! $this->application_deployment_queue->logs) {
- $new_log_entry['order'] = 1;
- } else {
- try {
- $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
- } catch (\JsonException $e) {
- // If existing logs are corrupted, start fresh
- $previous_logs = [];
- $new_log_entry['order'] = 1;
- }
- if (is_array($previous_logs)) {
- $new_log_entry['order'] = count($previous_logs) + 1;
- } else {
- $previous_logs = [];
- $new_log_entry['order'] = 1;
- }
- }
- $previous_logs[] = $new_log_entry;
+ $maxRetries = config('constants.ssh.max_retries');
+ $attempt = 0;
+ $lastError = null;
+ $commandExecuted = false;
+ while ($attempt < $maxRetries && ! $commandExecuted) {
try {
- $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR);
- } catch (\JsonException $e) {
- // If JSON encoding still fails, use fallback with invalid sequences replacement
- $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE);
- }
+ $this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors);
+ $commandExecuted = true;
+ } catch (\RuntimeException $e) {
+ $lastError = $e;
+ $errorMessage = $e->getMessage();
+ // Only retry if it's an SSH connection error and we haven't exhausted retries
+ if ($this->isRetryableSshError($errorMessage) && $attempt < $maxRetries - 1) {
+ $attempt++;
+ $delay = $this->calculateRetryDelay($attempt - 1);
- $this->application_deployment_queue->save();
+ // Track SSH retry event in Sentry
+ $this->trackSshRetryEvent($attempt, $maxRetries, $delay, $errorMessage, [
+ 'server' => $this->server->name ?? $this->server->ip ?? 'unknown',
+ 'command' => $this->redact_sensitive_info($command),
+ 'trait' => 'ExecuteRemoteCommand',
+ ]);
- if ($this->save) {
- if (data_get($this->saved_outputs, $this->save, null) === null) {
- data_set($this->saved_outputs, $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]);
+ // Add log entry for the retry
+ if (isset($this->application_deployment_queue)) {
+ $this->addRetryLogEntry($attempt, $maxRetries, $delay, $errorMessage);
+
+ // Check for cancellation during retry wait
+ $this->application_deployment_queue->refresh();
+ if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
+ throw new \RuntimeException('Deployment cancelled by user during retry', 69420);
+ }
+ }
+
+ sleep($delay);
} else {
- $this->saved_outputs[$this->save] = str($sanitized_output)->trim();
+ // Not retryable or max retries reached
+ throw $e;
}
}
- });
- $this->application_deployment_queue->update([
- 'current_process_id' => $process->id(),
- ]);
+ }
- $process_result = $process->wait();
- if ($process_result->exitCode() !== 0) {
- if (! $ignore_errors) {
+ // If we exhausted all retries and still failed
+ if (! $commandExecuted && $lastError) {
+ // Now we can set the status to FAILED since all retries have been exhausted
+ if (isset($this->application_deployment_queue)) {
$this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value;
$this->application_deployment_queue->save();
- throw new \RuntimeException($process_result->errorOutput());
}
+ throw $lastError;
}
});
}
+
+ /**
+ * Execute the actual command with process handling
+ */
+ 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) {
+ $output = str($output)->trim();
+ if ($output->startsWith('╔')) {
+ $output = "\n".$output;
+ }
+
+ // Sanitize output to ensure valid UTF-8 encoding before JSON encoding
+ $sanitized_output = sanitize_utf8_text($output);
+
+ $new_log_entry = [
+ 'command' => $this->redact_sensitive_info($command),
+ 'output' => $this->redact_sensitive_info($sanitized_output),
+ 'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout',
+ 'timestamp' => Carbon::now('UTC'),
+ 'hidden' => $hidden,
+ 'batch' => static::$batch_counter,
+ ];
+ if (! $this->application_deployment_queue->logs) {
+ $new_log_entry['order'] = 1;
+ } else {
+ try {
+ $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
+ } catch (\JsonException $e) {
+ // If existing logs are corrupted, start fresh
+ $previous_logs = [];
+ $new_log_entry['order'] = 1;
+ }
+ if (is_array($previous_logs)) {
+ $new_log_entry['order'] = count($previous_logs) + 1;
+ } else {
+ $previous_logs = [];
+ $new_log_entry['order'] = 1;
+ }
+ }
+ $previous_logs[] = $new_log_entry;
+
+ try {
+ $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR);
+ } catch (\JsonException $e) {
+ // If JSON encoding still fails, use fallback with invalid sequences replacement
+ $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE);
+ }
+
+ $this->application_deployment_queue->save();
+
+ if ($this->save) {
+ if (data_get($this->saved_outputs, $this->save, null) === null) {
+ data_set($this->saved_outputs, $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]);
+ } else {
+ $this->saved_outputs[$this->save] = str($sanitized_output)->trim();
+ }
+ }
+ });
+ $this->application_deployment_queue->update([
+ 'current_process_id' => $process->id(),
+ ]);
+
+ $process_result = $process->wait();
+ if ($process_result->exitCode() !== 0) {
+ if (! $ignore_errors) {
+ // Don't immediately set to FAILED - let the retry logic handle it
+ // This prevents premature status changes during retryable SSH errors
+ throw new \RuntimeException($process_result->errorOutput());
+ }
+ }
+ }
+
+ /**
+ * Add a log entry for SSH retry attempts
+ */
+ private function addRetryLogEntry(int $attempt, int $maxRetries, int $delay, string $errorMessage)
+ {
+ $retryMessage = "SSH connection failed. Retrying... (Attempt {$attempt}/{$maxRetries}, waiting {$delay}s)\nError: {$errorMessage}";
+
+ $new_log_entry = [
+ 'output' => $this->redact_sensitive_info($retryMessage),
+ 'type' => 'stdout',
+ 'timestamp' => Carbon::now('UTC'),
+ 'hidden' => false,
+ 'batch' => static::$batch_counter,
+ ];
+
+ if (! $this->application_deployment_queue->logs) {
+ $new_log_entry['order'] = 1;
+ $previous_logs = [];
+ } else {
+ try {
+ $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
+ } catch (\JsonException $e) {
+ $previous_logs = [];
+ $new_log_entry['order'] = 1;
+ }
+ if (is_array($previous_logs)) {
+ $new_log_entry['order'] = count($previous_logs) + 1;
+ } else {
+ $previous_logs = [];
+ $new_log_entry['order'] = 1;
+ }
+ }
+
+ $previous_logs[] = $new_log_entry;
+
+ try {
+ $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR);
+ } catch (\JsonException $e) {
+ $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE);
+ }
+
+ $this->application_deployment_queue->save();
+ }
}
diff --git a/app/Traits/HasSafeStringAttribute.php b/app/Traits/HasSafeStringAttribute.php
new file mode 100644
index 000000000..8a5d2ce77
--- /dev/null
+++ b/app/Traits/HasSafeStringAttribute.php
@@ -0,0 +1,25 @@
+attributes['name'] = $this->customizeName($sanitized);
+ }
+
+ protected function customizeName($value)
+ {
+ return $value; // Default: no customization
+ }
+
+ public function setDescriptionAttribute($value)
+ {
+ $this->attributes['description'] = strip_tags($value);
+ }
+}
diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php
new file mode 100644
index 000000000..a26481056
--- /dev/null
+++ b/app/Traits/SshRetryable.php
@@ -0,0 +1,174 @@
+getMessage();
+
+ // Check if it's retryable and not the last attempt
+ if ($this->isRetryableSshError($lastErrorMessage) && $attempt < $maxRetries - 1) {
+ $delay = $this->calculateRetryDelay($attempt);
+
+ // Track SSH retry event in Sentry
+ $this->trackSshRetryEvent($attempt + 1, $maxRetries, $delay, $lastErrorMessage, $context);
+
+ // Add deployment log if available (for ExecuteRemoteCommand trait)
+ if (isset($this->application_deployment_queue) && method_exists($this, 'addRetryLogEntry')) {
+ $this->addRetryLogEntry($attempt + 1, $maxRetries, $delay, $lastErrorMessage);
+ }
+
+ sleep($delay);
+
+ continue;
+ }
+
+ // Not retryable or max retries reached
+ break;
+ }
+ }
+
+ // All retries exhausted
+ if ($attempt >= $maxRetries) {
+ Log::error('SSH operation failed after all retries', array_merge($context, [
+ 'attempts' => $attempt,
+ 'error' => $lastErrorMessage,
+ ]));
+ }
+
+ if ($throwError && $lastError) {
+ // If the error message is empty, provide a more meaningful one
+ if (empty($lastErrorMessage) || trim($lastErrorMessage) === '') {
+ $contextInfo = isset($context['server']) ? " to server {$context['server']}" : '';
+ $attemptInfo = $attempt > 1 ? " after {$attempt} attempts" : '';
+ throw new \RuntimeException("SSH connection failed{$contextInfo}{$attemptInfo}", $lastError->getCode());
+ }
+ throw $lastError;
+ }
+
+ return null;
+ }
+
+ /**
+ * Track SSH retry event in Sentry
+ */
+ protected function trackSshRetryEvent(int $attempt, int $maxRetries, int $delay, string $errorMessage, array $context = []): void
+ {
+ // Only track in production/cloud instances
+ if (isDev() || ! config('constants.sentry.sentry_dsn')) {
+ return;
+ }
+
+ try {
+ app('sentry')->captureMessage(
+ 'SSH connection retry triggered',
+ \Sentry\Severity::warning(),
+ [
+ 'extra' => [
+ 'attempt' => $attempt,
+ 'max_retries' => $maxRetries,
+ 'delay_seconds' => $delay,
+ 'error_message' => $errorMessage,
+ 'context' => $context,
+ 'retryable_error' => true,
+ ],
+ 'tags' => [
+ 'component' => 'ssh_retry',
+ 'error_type' => 'connection_retry',
+ ],
+ ]
+ );
+ } catch (\Throwable $e) {
+ // Don't let Sentry tracking errors break the SSH retry flow
+ Log::warning('Failed to track SSH retry event in Sentry', [
+ 'error' => $e->getMessage(),
+ 'original_attempt' => $attempt,
+ ]);
+ }
+ }
+}
diff --git a/app/View/Components/Forms/Button.php b/app/View/Components/Forms/Button.php
index bf88d3f88..b54444261 100644
--- a/app/View/Components/Forms/Button.php
+++ b/app/View/Components/Forms/Button.php
@@ -4,6 +4,7 @@
use Closure;
use Illuminate\Contracts\View\View;
+use Illuminate\Support\Facades\Gate;
use Illuminate\View\Component;
class Button extends Component
@@ -17,7 +18,19 @@ public function __construct(
public ?string $modalId = null,
public string $defaultClass = 'button',
public bool $showLoadingIndicator = true,
+ public ?string $canGate = null,
+ public mixed $canResource = null,
+ public bool $autoDisable = true,
) {
+ // Handle authorization-based disabling
+ if ($this->canGate && $this->canResource && $this->autoDisable) {
+ $hasPermission = Gate::allows($this->canGate, $this->canResource);
+
+ if (! $hasPermission) {
+ $this->disabled = true;
+ }
+ }
+
if ($this->noStyle) {
$this->defaultClass = '';
}
diff --git a/app/View/Components/Forms/Checkbox.php b/app/View/Components/Forms/Checkbox.php
index 8db739642..ece7f0e35 100644
--- a/app/View/Components/Forms/Checkbox.php
+++ b/app/View/Components/Forms/Checkbox.php
@@ -4,6 +4,7 @@
use Closure;
use Illuminate\Contracts\View\View;
+use Illuminate\Support\Facades\Gate;
use Illuminate\View\Component;
class Checkbox extends Component
@@ -22,7 +23,20 @@ public function __construct(
public string|bool $instantSave = false,
public bool $disabled = false,
public string $defaultClass = 'dark:border-neutral-700 text-coolgray-400 focus:ring-warning dark:bg-coolgray-100 rounded-sm cursor-pointer dark:disabled:bg-base dark:disabled:cursor-not-allowed',
+ public ?string $canGate = null,
+ public mixed $canResource = null,
+ public bool $autoDisable = true,
) {
+ // Handle authorization-based disabling
+ if ($this->canGate && $this->canResource && $this->autoDisable) {
+ $hasPermission = Gate::allows($this->canGate, $this->canResource);
+
+ if (! $hasPermission) {
+ $this->disabled = true;
+ $this->instantSave = false; // Disable instant save for unauthorized users
+ }
+ }
+
if ($this->disabled) {
$this->defaultClass .= ' opacity-40';
}
diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php
index 7283ef20f..83c98c0df 100644
--- a/app/View/Components/Forms/Input.php
+++ b/app/View/Components/Forms/Input.php
@@ -4,6 +4,7 @@
use Closure;
use Illuminate\Contracts\View\View;
+use Illuminate\Support\Facades\Gate;
use Illuminate\View\Component;
use Visus\Cuid2\Cuid2;
@@ -25,7 +26,20 @@ public function __construct(
public string $autocomplete = 'off',
public ?int $minlength = null,
public ?int $maxlength = null,
- ) {}
+ public bool $autofocus = false,
+ public ?string $canGate = null,
+ public mixed $canResource = null,
+ public bool $autoDisable = true,
+ ) {
+ // Handle authorization-based disabling
+ if ($this->canGate && $this->canResource && $this->autoDisable) {
+ $hasPermission = Gate::allows($this->canGate, $this->canResource);
+
+ if (! $hasPermission) {
+ $this->disabled = true;
+ }
+ }
+ }
public function render(): View|Closure|string
{
diff --git a/app/View/Components/Forms/Select.php b/app/View/Components/Forms/Select.php
index feb4bf343..49b69136b 100644
--- a/app/View/Components/Forms/Select.php
+++ b/app/View/Components/Forms/Select.php
@@ -4,6 +4,7 @@
use Closure;
use Illuminate\Contracts\View\View;
+use Illuminate\Support\Facades\Gate;
use Illuminate\View\Component;
use Visus\Cuid2\Cuid2;
@@ -19,9 +20,19 @@ public function __construct(
public ?string $helper = null,
public bool $required = false,
public bool $disabled = false,
- public string $defaultClass = 'select w-full'
+ public string $defaultClass = 'select w-full',
+ public ?string $canGate = null,
+ public mixed $canResource = null,
+ public bool $autoDisable = true,
) {
- //
+ // Handle authorization-based disabling
+ if ($this->canGate && $this->canResource && $this->autoDisable) {
+ $hasPermission = Gate::allows($this->canGate, $this->canResource);
+
+ if (! $hasPermission) {
+ $this->disabled = true;
+ }
+ }
}
/**
diff --git a/app/View/Components/Forms/Textarea.php b/app/View/Components/Forms/Textarea.php
index 6081c2a8a..3148d2566 100644
--- a/app/View/Components/Forms/Textarea.php
+++ b/app/View/Components/Forms/Textarea.php
@@ -4,6 +4,7 @@
use Closure;
use Illuminate\Contracts\View\View;
+use Illuminate\Support\Facades\Gate;
use Illuminate\View\Component;
use Visus\Cuid2\Cuid2;
@@ -33,8 +34,18 @@ public function __construct(
public string $defaultClassInput = 'input',
public ?int $minlength = null,
public ?int $maxlength = null,
+ public ?string $canGate = null,
+ public mixed $canResource = null,
+ public bool $autoDisable = true,
) {
- //
+ // Handle authorization-based disabling
+ if ($this->canGate && $this->canResource && $this->autoDisable) {
+ $hasPermission = Gate::allows($this->canGate, $this->canResource);
+
+ if (! $hasPermission) {
+ $this->disabled = true;
+ }
+ }
}
/**
diff --git a/app/View/Components/services/advanced.php b/app/View/Components/Services/Advanced.php
similarity index 85%
rename from app/View/Components/services/advanced.php
rename to app/View/Components/Services/Advanced.php
index 8104eaad4..99729a262 100644
--- a/app/View/Components/services/advanced.php
+++ b/app/View/Components/Services/Advanced.php
@@ -1,13 +1,13 @@
+- [ ] #1 Docker BuildKit cache mounts are added to Composer dependency installation in production Dockerfile
+- [ ] #2 Docker BuildKit cache mounts are added to NPM dependency installation in production Dockerfile
+- [ ] #3 GitHub Actions BuildX setup is configured for both AMD64 and AARCH64 jobs
+- [ ] #4 Registry cache-from and cache-to configurations are implemented for both architecture builds
+- [ ] #5 Build time reduction of at least 40% is achieved in staging builds
+- [ ] #6 GitHub Actions minutes consumption is reduced compared to baseline
+- [ ] #7 All existing build functionality remains intact with no regressions
+
+
+## Implementation Plan
+
+1. Modify docker/production/Dockerfile to add BuildKit cache mounts:
+ - Add cache mount for Composer dependencies at line 30: --mount=type=cache,target=/var/www/.composer/cache
+ - Add cache mount for NPM dependencies at line 41: --mount=type=cache,target=/root/.npm
+
+2. Update .github/workflows/coolify-staging-build.yml for AMD64 job:
+ - Add docker/setup-buildx-action@v3 step after checkout
+ - Configure cache-from and cache-to parameters in build-push-action
+ - Use registry caching with buildcache-amd64 tags
+
+3. Update .github/workflows/coolify-staging-build.yml for AARCH64 job:
+ - Add docker/setup-buildx-action@v3 step after checkout
+ - Configure cache-from and cache-to parameters in build-push-action
+ - Use registry caching with buildcache-aarch64 tags
+
+4. Test implementation:
+ - Measure baseline build times before changes
+ - Deploy changes and monitor initial build (will be slower due to cache population)
+ - Measure subsequent build times to verify 40%+ improvement
+ - Validate all build outputs and functionality remain unchanged
+
+5. Monitor and validate:
+ - Track GitHub Actions minutes consumption reduction
+ - Ensure Docker registry storage usage is reasonable
+ - Verify no build failures or regressions introduced
diff --git a/backlog/tasks/task-00001.01 - Add-BuildKit-cache-mounts-to-Dockerfile.md b/backlog/tasks/task-00001.01 - Add-BuildKit-cache-mounts-to-Dockerfile.md
new file mode 100644
index 000000000..93fa3e431
--- /dev/null
+++ b/backlog/tasks/task-00001.01 - Add-BuildKit-cache-mounts-to-Dockerfile.md
@@ -0,0 +1,24 @@
+---
+id: task-00001.01
+title: Add BuildKit cache mounts to Dockerfile
+status: To Do
+assignee: []
+created_date: '2025-08-26 12:19'
+labels:
+ - docker
+ - buildkit
+ - performance
+ - dockerfile
+dependencies: []
+parent_task_id: task-00001
+priority: high
+---
+
+## Description
+
+Modify the production Dockerfile to include BuildKit cache mounts for Composer and NPM dependencies to speed up subsequent builds by reusing cached dependency installations
+
+## Acceptance Criteria
+
+- [ ] #1 Cache mount for Composer dependencies is added at line 30 with --mount=type=cache target=/var/www/.composer/cache,Cache mount for NPM dependencies is added at line 41 with --mount=type=cache target=/root/.npm,Dockerfile syntax remains valid and builds successfully,All existing functionality is preserved with no regressions
+
diff --git a/backlog/tasks/task-00001.02 - Configure-BuildX-and-registry-caching-for-AMD64-staging-builds.md b/backlog/tasks/task-00001.02 - Configure-BuildX-and-registry-caching-for-AMD64-staging-builds.md
new file mode 100644
index 000000000..60ac514f6
--- /dev/null
+++ b/backlog/tasks/task-00001.02 - Configure-BuildX-and-registry-caching-for-AMD64-staging-builds.md
@@ -0,0 +1,24 @@
+---
+id: task-00001.02
+title: Configure BuildX and registry caching for AMD64 staging builds
+status: To Do
+assignee: []
+created_date: '2025-08-26 12:19'
+labels:
+ - github-actions
+ - buildx
+ - caching
+ - amd64
+dependencies: []
+parent_task_id: task-00001
+priority: high
+---
+
+## Description
+
+Update the GitHub Actions workflow to add BuildX setup and configure registry-based caching for the AMD64 build job to leverage Docker layer caching across builds
+
+## Acceptance Criteria
+
+- [ ] #1 docker/setup-buildx-action@v3 step is added after checkout in AMD64 job,Registry cache configuration is added to build-push-action with cache-from and cache-to parameters,Cache tags use buildcache-amd64 naming convention for architecture-specific caching,Build job runs successfully with caching enabled,No impact on existing build outputs or functionality
+
diff --git a/backlog/tasks/task-00001.03 - Configure-BuildX-and-registry-caching-for-AARCH64-staging-builds.md b/backlog/tasks/task-00001.03 - Configure-BuildX-and-registry-caching-for-AARCH64-staging-builds.md
new file mode 100644
index 000000000..3dd730d34
--- /dev/null
+++ b/backlog/tasks/task-00001.03 - Configure-BuildX-and-registry-caching-for-AARCH64-staging-builds.md
@@ -0,0 +1,25 @@
+---
+id: task-00001.03
+title: Configure BuildX and registry caching for AARCH64 staging builds
+status: To Do
+assignee: []
+created_date: '2025-08-26 12:19'
+labels:
+ - github-actions
+ - buildx
+ - caching
+ - aarch64
+ - self-hosted
+dependencies: []
+parent_task_id: task-00001
+priority: high
+---
+
+## Description
+
+Update the GitHub Actions workflow to add BuildX setup and configure registry-based caching for the AARCH64 build job running on self-hosted ARM64 runners
+
+## Acceptance Criteria
+
+- [ ] #1 docker/setup-buildx-action@v3 step is added after checkout in AARCH64 job,Registry cache configuration is added to build-push-action with cache-from and cache-to parameters,Cache tags use buildcache-aarch64 naming convention for architecture-specific caching,Build job runs successfully on self-hosted ARM64 runner with caching enabled,No impact on existing build outputs or functionality
+
diff --git a/backlog/tasks/task-00001.04 - Establish-build-time-baseline-measurements.md b/backlog/tasks/task-00001.04 - Establish-build-time-baseline-measurements.md
new file mode 100644
index 000000000..6fa997663
--- /dev/null
+++ b/backlog/tasks/task-00001.04 - Establish-build-time-baseline-measurements.md
@@ -0,0 +1,24 @@
+---
+id: task-00001.04
+title: Establish build time baseline measurements
+status: To Do
+assignee: []
+created_date: '2025-08-26 12:19'
+labels:
+ - performance
+ - testing
+ - baseline
+ - measurement
+dependencies: []
+parent_task_id: task-00001
+priority: medium
+---
+
+## Description
+
+Measure and document current staging build times for both AMD64 and AARCH64 architectures before implementing caching optimizations to establish a performance baseline for comparison
+
+## Acceptance Criteria
+
+- [ ] #1 Baseline build times are measured for at least 3 consecutive AMD64 builds,Baseline build times are measured for at least 3 consecutive AARCH64 builds,Average build time and GitHub Actions minutes consumption are documented,Baseline measurements include both cold builds and any existing warm builds,Results are documented in a format suitable for comparing against post-optimization builds
+
diff --git a/backlog/tasks/task-00001.05 - Validate-caching-implementation-and-measure-performance-improvements.md b/backlog/tasks/task-00001.05 - Validate-caching-implementation-and-measure-performance-improvements.md
new file mode 100644
index 000000000..6a11168da
--- /dev/null
+++ b/backlog/tasks/task-00001.05 - Validate-caching-implementation-and-measure-performance-improvements.md
@@ -0,0 +1,28 @@
+---
+id: task-00001.05
+title: Validate caching implementation and measure performance improvements
+status: To Do
+assignee: []
+created_date: '2025-08-26 12:19'
+labels:
+ - testing
+ - performance
+ - validation
+ - measurement
+dependencies:
+ - task-00001.01
+ - task-00001.02
+ - task-00001.03
+ - task-00001.04
+parent_task_id: task-00001
+priority: high
+---
+
+## Description
+
+Test the complete Docker build caching implementation by running multiple staging builds and measuring performance improvements to ensure the 40% build time reduction target is achieved
+
+## Acceptance Criteria
+
+- [ ] #1 First build after cache implementation runs successfully (expected slower due to cache population),Second and subsequent builds show significant time reduction compared to baseline,Build time reduction of at least 40% is achieved and documented,GitHub Actions minutes consumption is reduced compared to baseline measurements,All Docker images function identically to pre-optimization builds,No build failures or regressions are introduced by caching changes
+
diff --git a/backlog/tasks/task-00001.06 - Document-cache-optimization-results-and-create-production-workflow-plan.md b/backlog/tasks/task-00001.06 - Document-cache-optimization-results-and-create-production-workflow-plan.md
new file mode 100644
index 000000000..3749e58f3
--- /dev/null
+++ b/backlog/tasks/task-00001.06 - Document-cache-optimization-results-and-create-production-workflow-plan.md
@@ -0,0 +1,25 @@
+---
+id: task-00001.06
+title: Document cache optimization results and create production workflow plan
+status: To Do
+assignee: []
+created_date: '2025-08-26 12:19'
+labels:
+ - documentation
+ - planning
+ - production
+ - analysis
+dependencies:
+ - task-00001.05
+parent_task_id: task-00001
+priority: low
+---
+
+## Description
+
+Document the staging build caching results and create a detailed plan for applying the same optimizations to the production build workflow if staging results meet performance targets
+
+## Acceptance Criteria
+
+- [ ] #1 Performance improvement results are documented with before/after metrics,Cost savings in GitHub Actions minutes are calculated and documented,Analysis of Docker registry storage impact is provided,Detailed plan for production workflow optimization is created,Recommendations for cache retention policies and cleanup strategies are provided,Documentation includes rollback procedures if issues arise
+
diff --git a/backlog/tasks/task-00002 - Fix-Docker-cleanup-irregular-scheduling-in-cloud-environment.md b/backlog/tasks/task-00002 - Fix-Docker-cleanup-irregular-scheduling-in-cloud-environment.md
new file mode 100644
index 000000000..d0e63456b
--- /dev/null
+++ b/backlog/tasks/task-00002 - Fix-Docker-cleanup-irregular-scheduling-in-cloud-environment.md
@@ -0,0 +1,82 @@
+---
+id: task-00002
+title: Fix Docker cleanup irregular scheduling in cloud environment
+status: Done
+assignee:
+ - '@claude'
+created_date: '2025-08-26 12:17'
+updated_date: '2025-08-26 12:26'
+labels:
+ - backend
+ - performance
+ - cloud
+dependencies: []
+priority: high
+---
+
+## Description
+
+Docker cleanup jobs are running at irregular intervals instead of hourly as configured (0 * * * *) in the cloud environment with 2 Horizon workers and thousands of servers. The issue stems from the ServerManagerJob processing servers sequentially with a frozen execution time, causing timing mismatches when evaluating cron expressions for large server counts.
+
+## Acceptance Criteria
+
+- [x] #1 Docker cleanup runs consistently at the configured hourly intervals
+- [x] #2 All eligible servers receive cleanup jobs when due
+- [x] #3 Solution handles thousands of servers efficiently
+- [x] #4 Maintains backwards compatibility with existing settings
+- [x] #5 Cloud subscription checks are properly enforced
+
+
+## Implementation Plan
+
+1. Add processDockerCleanups() method to ScheduledJobManager
+ - Implement method to fetch all eligible servers
+ - Apply frozen execution time for consistent cron evaluation
+ - Check server functionality and cloud subscription status
+ - Dispatch DockerCleanupJob for servers where cleanup is due
+
+2. Implement helper methods in ScheduledJobManager
+ - getServersForCleanup(): Fetch servers with proper cloud/self-hosted filtering
+ - shouldProcessDockerCleanup(): Validate server eligibility
+ - Reuse existing shouldRunNow() method with frozen execution time
+
+3. Remove Docker cleanup logic from ServerManagerJob
+ - Delete lines 136-150 that handle Docker cleanup scheduling
+ - Keep other server management tasks intact
+
+4. Test the implementation
+ - Verify hourly execution with test servers
+ - Check timezone handling
+ - Validate cloud subscription filtering
+ - Monitor for duplicate job prevention via WithoutOverlapping middleware
+
+5. Deploy strategy
+ - First deploy updated ScheduledJobManager
+ - Monitor logs for successful hourly executions
+ - Once confirmed, remove cleanup from ServerManagerJob
+ - No database migrations required
+
+## Implementation Notes
+
+Successfully migrated Docker cleanup scheduling from ServerManagerJob to ScheduledJobManager.
+
+**Changes Made:**
+1. Added processDockerCleanups() method to ScheduledJobManager that processes all servers with a single frozen execution time
+2. Implemented getServersForCleanup() to fetch servers with proper cloud/self-hosted filtering
+3. Implemented shouldProcessDockerCleanup() for server eligibility validation
+4. Removed Docker cleanup logic from ServerManagerJob (lines 136-150)
+
+**Key Improvements:**
+- All servers now evaluated against the same timestamp, ensuring consistent hourly execution
+- Proper cloud subscription checks maintained
+- Backwards compatible - no database migrations or settings changes required
+- Follows the same proven pattern used for database backups
+
+**Files Modified:**
+- app/Jobs/ScheduledJobManager.php: Added Docker cleanup processing
+- app/Jobs/ServerManagerJob.php: Removed Docker cleanup logic
+
+**Testing:**
+- Syntax validation passed
+- Code formatting verified with Laravel Pint
+- PHPStan analysis completed (existing warnings unrelated to changes)
diff --git a/backlog/tasks/task-00003 - Simplify-resource-operations-UI-replace-boxes-with-dropdown-selections.md b/backlog/tasks/task-00003 - Simplify-resource-operations-UI-replace-boxes-with-dropdown-selections.md
new file mode 100644
index 000000000..38aa18209
--- /dev/null
+++ b/backlog/tasks/task-00003 - Simplify-resource-operations-UI-replace-boxes-with-dropdown-selections.md
@@ -0,0 +1,30 @@
+---
+id: task-00003
+title: Simplify resource operations UI - replace boxes with dropdown selections
+status: To Do
+assignee: []
+created_date: '2025-08-26 13:22'
+updated_date: '2025-08-26 13:22'
+labels:
+ - ui
+ - frontend
+ - livewire
+dependencies: []
+priority: medium
+---
+
+## Description
+
+Replace the current box-based layout in resource-operations.blade.php with clean dropdown selections to improve UX when there are many servers, projects, or environments. The current interface becomes overwhelming and cluttered with multiple modal confirmation boxes for each option.
+
+## Acceptance Criteria
+
+- [ ] #1 Clone section shows a dropdown to select server/destination instead of multiple boxes
+- [ ] #2 Move section shows a dropdown to select project/environment instead of multiple boxes
+- [ ] #3 Single "Clone Resource" button that triggers modal after dropdown selection
+- [ ] #4 Single "Move Resource" button that triggers modal after dropdown selection
+- [ ] #5 Authorization warnings remain in place for users without permissions
+- [ ] #6 All existing functionality preserved (cloning, moving, success messages)
+- [ ] #7 Clean, simple interface that scales well with many options
+- [ ] #8 Mobile-friendly dropdown interface
+
diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php
index 307c7ed1b..5d0f9a2a7 100644
--- a/bootstrap/helpers/api.php
+++ b/bootstrap/helpers/api.php
@@ -176,4 +176,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('private_key_uuid');
$request->offsetUnset('use_build_server');
$request->offsetUnset('is_static');
+ $request->offsetUnset('force_domain_override');
}
diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php
index 919b2bde5..db7767c1e 100644
--- a/bootstrap/helpers/applications.php
+++ b/bootstrap/helpers/applications.php
@@ -1,12 +1,15 @@
id,
);
- } elseif (next_queuable($server_id, $application_id, $commit)) {
+ } elseif (next_queuable($server_id, $application_id, $commit, $pull_request_id)) {
ApplicationDeploymentJob::dispatch(
application_deployment_queue_id: $deployment->id,
);
@@ -93,32 +96,31 @@ function force_start_deployment(ApplicationDeploymentQueue $deployment)
function queue_next_deployment(Application $application)
{
$server_id = $application->destination->server_id;
- $next_found = ApplicationDeploymentQueue::where('server_id', $server_id)->where('status', ApplicationDeploymentStatus::QUEUED)->get()->sortBy('created_at')->first();
- if ($next_found) {
- $next_found->update([
- 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
- ]);
+ $queued_deployments = ApplicationDeploymentQueue::where('server_id', $server_id)
+ ->where('status', ApplicationDeploymentStatus::QUEUED)
+ ->get()
+ ->sortBy('created_at');
- ApplicationDeploymentJob::dispatch(
- application_deployment_queue_id: $next_found->id,
- );
+ foreach ($queued_deployments as $next_deployment) {
+ // Check if this queued deployment can actually run
+ if (next_queuable($next_deployment->server_id, $next_deployment->application_id, $next_deployment->commit, $next_deployment->pull_request_id)) {
+ $next_deployment->update([
+ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
+ ]);
+
+ ApplicationDeploymentJob::dispatch(
+ application_deployment_queue_id: $next_deployment->id,
+ );
+ }
}
}
-function next_queuable(string $server_id, string $application_id, string $commit = 'HEAD'): bool
+function next_queuable(string $server_id, string $application_id, string $commit = 'HEAD', int $pull_request_id = 0): bool
{
- // Check if there's already a deployment in progress for this application and commit
- $existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id)
- ->where('commit', $commit)
- ->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)
- ->first();
-
- if ($existing_deployment) {
- return false;
- }
-
- // Check if there's any deployment in progress for this application
+ // Check if there's already a deployment in progress for this application with the same pull_request_id
+ // This allows normal deployments and PR deployments to run concurrently
$in_progress = ApplicationDeploymentQueue::where('application_id', $application_id)
+ ->where('pull_request_id', $pull_request_id)
->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)
->exists();
@@ -142,13 +144,15 @@ function next_queuable(string $server_id, string $application_id, string $commit
function next_after_cancel(?Server $server = null)
{
if ($server) {
- $next_found = ApplicationDeploymentQueue::where('server_id', data_get($server, 'id'))->where('status', ApplicationDeploymentStatus::QUEUED)->get()->sortBy('created_at');
+ $next_found = ApplicationDeploymentQueue::where('server_id', data_get($server, 'id'))
+ ->where('status', ApplicationDeploymentStatus::QUEUED)
+ ->get()
+ ->sortBy('created_at');
+
if ($next_found->count() > 0) {
foreach ($next_found as $next) {
- $server = Server::find($next->server_id);
- $concurrent_builds = $server->settings->concurrent_builds;
- $inprogress_deployments = ApplicationDeploymentQueue::where('server_id', $next->server_id)->whereIn('status', [ApplicationDeploymentStatus::QUEUED])->get()->sortByDesc('created_at');
- if ($inprogress_deployments->count() < $concurrent_builds) {
+ // Use next_queuable to properly check if this deployment can run
+ if (next_queuable($next->server_id, $next->application_id, $next->commit, $next->pull_request_id)) {
$next->update([
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
@@ -157,8 +161,195 @@ function next_after_cancel(?Server $server = null)
application_deployment_queue_id: $next->id,
);
}
- break;
}
}
}
}
+
+function clone_application(Application $source, $destination, array $overrides = [], bool $cloneVolumeData = false): Application
+{
+ $uuid = $overrides['uuid'] ?? (string) new Cuid2;
+ $server = $destination->server;
+
+ // Prepare name and URL
+ $name = $overrides['name'] ?? 'clone-of-'.str($source->name)->limit(20).'-'.$uuid;
+ $applicationSettings = $source->settings;
+ $url = $overrides['fqdn'] ?? $source->fqdn;
+
+ if ($server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
+ $url = generateUrl(server: $server, random: $uuid);
+ }
+
+ // Clone the application
+ $newApplication = $source->replicate([
+ 'id',
+ 'created_at',
+ 'updated_at',
+ 'additional_servers_count',
+ 'additional_networks_count',
+ ])->fill(array_merge([
+ 'uuid' => $uuid,
+ 'name' => $name,
+ 'fqdn' => $url,
+ 'status' => 'exited',
+ 'destination_id' => $destination->id,
+ ], $overrides));
+ $newApplication->save();
+
+ // Update custom labels if needed
+ if ($newApplication->destination->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
+ $customLabels = str(implode('|coolify|', generateLabelsApplication($newApplication)))->replace('|coolify|', "\n");
+ $newApplication->custom_labels = base64_encode($customLabels);
+ $newApplication->save();
+ }
+
+ // Clone settings
+ $newApplication->settings()->delete();
+ if ($applicationSettings) {
+ $newApplicationSettings = $applicationSettings->replicate([
+ 'id',
+ 'created_at',
+ 'updated_at',
+ ])->fill([
+ 'application_id' => $newApplication->id,
+ ]);
+ $newApplicationSettings->save();
+ }
+
+ // Clone tags
+ $tags = $source->tags;
+ foreach ($tags as $tag) {
+ $newApplication->tags()->attach($tag->id);
+ }
+
+ // Clone scheduled tasks
+ $scheduledTasks = $source->scheduled_tasks()->get();
+ foreach ($scheduledTasks as $task) {
+ $newTask = $task->replicate([
+ 'id',
+ 'created_at',
+ 'updated_at',
+ ])->fill([
+ 'uuid' => (string) new Cuid2,
+ 'application_id' => $newApplication->id,
+ 'team_id' => currentTeam()->id,
+ ]);
+ $newTask->save();
+ }
+
+ // Clone previews with FQDN regeneration
+ $applicationPreviews = $source->previews()->get();
+ foreach ($applicationPreviews as $preview) {
+ $newPreview = $preview->replicate([
+ 'id',
+ 'created_at',
+ 'updated_at',
+ ])->fill([
+ 'uuid' => (string) new Cuid2,
+ 'application_id' => $newApplication->id,
+ 'status' => 'exited',
+ 'fqdn' => null,
+ 'docker_compose_domains' => null,
+ ]);
+ $newPreview->save();
+
+ // Regenerate FQDN for the cloned preview
+ if ($newApplication->build_pack === 'dockercompose') {
+ $newPreview->generate_preview_fqdn_compose();
+ } else {
+ $newPreview->generate_preview_fqdn();
+ }
+ }
+
+ // Clone persistent volumes
+ $persistentVolumes = $source->persistentStorages()->get();
+ foreach ($persistentVolumes as $volume) {
+ $newName = '';
+ if (str_starts_with($volume->name, $source->uuid)) {
+ $newName = str($volume->name)->replace($source->uuid, $newApplication->uuid);
+ } else {
+ $newName = $newApplication->uuid.'-'.str($volume->name)->afterLast('-');
+ }
+
+ $newPersistentVolume = $volume->replicate([
+ 'id',
+ 'created_at',
+ 'updated_at',
+ ])->fill([
+ 'name' => $newName,
+ 'resource_id' => $newApplication->id,
+ ]);
+ $newPersistentVolume->save();
+
+ if ($cloneVolumeData) {
+ try {
+ StopApplication::dispatch($source, false, false);
+ $sourceVolume = $volume->name;
+ $targetVolume = $newPersistentVolume->name;
+ $sourceServer = $source->destination->server;
+ $targetServer = $newApplication->destination->server;
+
+ VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
+
+ queue_application_deployment(
+ deployment_uuid: (string) new Cuid2,
+ application: $source,
+ server: $sourceServer,
+ destination: $source->destination,
+ no_questions_asked: true
+ );
+ } catch (\Exception $e) {
+ \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
+ }
+ }
+ }
+
+ // Clone file storages
+ $fileStorages = $source->fileStorages()->get();
+ foreach ($fileStorages as $storage) {
+ $newStorage = $storage->replicate([
+ 'id',
+ 'created_at',
+ 'updated_at',
+ ])->fill([
+ 'resource_id' => $newApplication->id,
+ ]);
+ $newStorage->save();
+ }
+
+ // Clone production environment variables without triggering the created hook
+ $environmentVariables = $source->environment_variables()->get();
+ foreach ($environmentVariables as $environmentVariable) {
+ \App\Models\EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) {
+ $newEnvironmentVariable = $environmentVariable->replicate([
+ 'id',
+ 'created_at',
+ 'updated_at',
+ ])->fill([
+ 'resourceable_id' => $newApplication->id,
+ 'resourceable_type' => $newApplication->getMorphClass(),
+ 'is_preview' => false,
+ ]);
+ $newEnvironmentVariable->save();
+ });
+ }
+
+ // Clone preview environment variables
+ $previewEnvironmentVariables = $source->environment_variables_preview()->get();
+ foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) {
+ \App\Models\EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) {
+ $newPreviewEnvironmentVariable = $previewEnvironmentVariable->replicate([
+ 'id',
+ 'created_at',
+ 'updated_at',
+ ])->fill([
+ 'resourceable_id' => $newApplication->id,
+ 'resourceable_type' => $newApplication->getMorphClass(),
+ 'is_preview' => true,
+ ]);
+ $newPreviewEnvironmentVariable->save();
+ });
+ }
+
+ return $newApplication;
+}
diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php
index 48962f89c..5dbd46b5e 100644
--- a/bootstrap/helpers/databases.php
+++ b/bootstrap/helpers/databases.php
@@ -237,11 +237,18 @@ function removeOldBackups($backup): void
{
try {
if ($backup->executions) {
- $localBackupsToDelete = deleteOldBackupsLocally($backup);
- if ($localBackupsToDelete->isNotEmpty()) {
+ // If local backup is disabled, mark all executions as having local storage deleted
+ if ($backup->disable_local_backup && $backup->save_s3) {
$backup->executions()
- ->whereIn('id', $localBackupsToDelete->pluck('id'))
+ ->where('local_storage_deleted', false)
->update(['local_storage_deleted' => true]);
+ } else {
+ $localBackupsToDelete = deleteOldBackupsLocally($backup);
+ if ($localBackupsToDelete->isNotEmpty()) {
+ $backup->executions()
+ ->whereIn('id', $localBackupsToDelete->pluck('id'))
+ ->update(['local_storage_deleted' => true]);
+ }
}
}
@@ -254,10 +261,18 @@ function removeOldBackups($backup): void
}
}
- $backup->executions()
- ->where('local_storage_deleted', true)
- ->where('s3_storage_deleted', true)
- ->delete();
+ // Delete executions where both local and S3 storage are marked as deleted
+ // or where only S3 is enabled and S3 storage is deleted
+ if ($backup->disable_local_backup && $backup->save_s3) {
+ $backup->executions()
+ ->where('s3_storage_deleted', true)
+ ->delete();
+ } else {
+ $backup->executions()
+ ->where('local_storage_deleted', true)
+ ->where('s3_storage_deleted', true)
+ ->delete();
+ }
} catch (\Exception $e) {
throw $e;
diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
index 944c51e3c..1491e4712 100644
--- a/bootstrap/helpers/docker.php
+++ b/bootstrap/helpers/docker.php
@@ -256,12 +256,12 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
if (str($MINIO_BROWSER_REDIRECT_URL->value ?? '')->isEmpty()) {
$MINIO_BROWSER_REDIRECT_URL->update([
- 'value' => generateFqdn($server, 'console-'.$uuid, true),
+ 'value' => generateUrl(server: $server, random: 'console-'.$uuid, forceHttps: true),
]);
}
if (str($MINIO_SERVER_URL->value ?? '')->isEmpty()) {
$MINIO_SERVER_URL->update([
- 'value' => generateFqdn($server, 'minio-'.$uuid, true),
+ 'value' => generateUrl(server: $server, random: 'minio-'.$uuid, forceHttps: true),
]);
}
$payload = collect([
@@ -279,12 +279,12 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
if (str($LOGTO_ENDPOINT->value ?? '')->isEmpty()) {
$LOGTO_ENDPOINT->update([
- 'value' => generateFqdn($server, 'logto-'.$uuid),
+ 'value' => generateUrl(server: $server, random: 'logto-'.$uuid),
]);
}
if (str($LOGTO_ADMIN_ENDPOINT->value ?? '')->isEmpty()) {
$LOGTO_ADMIN_ENDPOINT->update([
- 'value' => generateFqdn($server, 'logto-admin-'.$uuid),
+ 'value' => generateUrl(server: $server, random: 'logto-admin-'.$uuid),
]);
}
$payload = collect([
@@ -1093,19 +1093,18 @@ function getContainerLogs(Server $server, string $container_id, int $lines = 100
{
if ($server->isSwarm()) {
$output = instant_remote_process([
- "docker service logs -n {$lines} {$container_id}",
+ "docker service logs -n {$lines} {$container_id} 2>&1",
], $server);
} else {
$output = instant_remote_process([
- "docker logs -n {$lines} {$container_id}",
+ "docker logs -n {$lines} {$container_id} 2>&1",
], $server);
}
- $output .= removeAnsiColors($output);
+ $output = removeAnsiColors($output);
return $output;
}
-
function escapeEnvVariables($value)
{
$search = ['\\', "\r", "\t", "\x0", '"', "'"];
diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php
new file mode 100644
index 000000000..5b665890c
--- /dev/null
+++ b/bootstrap/helpers/domains.php
@@ -0,0 +1,237 @@
+team();
+ }
+
+ if ($resource) {
+ if ($resource->getMorphClass() === Application::class && $resource->build_pack === 'dockercompose') {
+ $domains = data_get(json_decode($resource->docker_compose_domains, true), '*.domain');
+ $domains = collect($domains);
+ } else {
+ $domains = collect($resource->fqdns);
+ }
+ } elseif ($domain) {
+ $domains = collect([$domain]);
+ } else {
+ return ['conflicts' => [], 'hasConflicts' => false];
+ }
+
+ $domains = $domains->map(function ($domain) {
+ if (str($domain)->endsWith('/')) {
+ $domain = str($domain)->beforeLast('/');
+ }
+
+ return str($domain);
+ });
+
+ // Filter applications by team if we have a current team
+ $appsQuery = Application::query();
+ if ($currentTeam) {
+ $appsQuery = $appsQuery->whereHas('environment.project', function ($query) use ($currentTeam) {
+ $query->where('team_id', $currentTeam->id);
+ });
+ }
+ $apps = $appsQuery->get();
+ foreach ($apps as $app) {
+ $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
+ foreach ($list_of_domains as $domain) {
+ if (str($domain)->endsWith('/')) {
+ $domain = str($domain)->beforeLast('/');
+ }
+ $naked_domain = str($domain)->value();
+ if ($domains->contains($naked_domain)) {
+ if (data_get($resource, 'uuid')) {
+ if ($resource->uuid !== $app->uuid) {
+ $conflicts[] = [
+ 'domain' => $naked_domain,
+ 'resource_name' => $app->name,
+ 'resource_link' => $app->link(),
+ 'resource_type' => 'application',
+ 'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
+ ];
+ }
+ } elseif ($domain) {
+ $conflicts[] = [
+ 'domain' => $naked_domain,
+ 'resource_name' => $app->name,
+ 'resource_link' => $app->link(),
+ 'resource_type' => 'application',
+ 'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
+ ];
+ }
+ }
+ }
+ }
+
+ // Filter service applications by team if we have a current team
+ $serviceAppsQuery = ServiceApplication::query();
+ if ($currentTeam) {
+ $serviceAppsQuery = $serviceAppsQuery->whereHas('service.environment.project', function ($query) use ($currentTeam) {
+ $query->where('team_id', $currentTeam->id);
+ });
+ }
+ $apps = $serviceAppsQuery->get();
+ foreach ($apps as $app) {
+ $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
+ foreach ($list_of_domains as $domain) {
+ if (str($domain)->endsWith('/')) {
+ $domain = str($domain)->beforeLast('/');
+ }
+ $naked_domain = str($domain)->value();
+ if ($domains->contains($naked_domain)) {
+ if (data_get($resource, 'uuid')) {
+ if ($resource->uuid !== $app->uuid) {
+ $conflicts[] = [
+ 'domain' => $naked_domain,
+ 'resource_name' => $app->service->name,
+ 'resource_link' => $app->service->link(),
+ 'resource_type' => 'service',
+ 'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'",
+ ];
+ }
+ } elseif ($domain) {
+ $conflicts[] = [
+ 'domain' => $naked_domain,
+ 'resource_name' => $app->service->name,
+ 'resource_link' => $app->service->link(),
+ 'resource_type' => 'service',
+ 'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'",
+ ];
+ }
+ }
+ }
+ }
+
+ if ($resource) {
+ $settings = instanceSettings();
+ if (data_get($settings, 'fqdn')) {
+ $domain = data_get($settings, 'fqdn');
+ if (str($domain)->endsWith('/')) {
+ $domain = str($domain)->beforeLast('/');
+ }
+ $naked_domain = str($domain)->value();
+ if ($domains->contains($naked_domain)) {
+ $conflicts[] = [
+ 'domain' => $naked_domain,
+ 'resource_name' => 'Coolify Instance',
+ 'resource_link' => '#',
+ 'resource_type' => 'instance',
+ 'message' => "Domain $naked_domain is already in use by this Coolify instance",
+ ];
+ }
+ }
+ }
+
+ return [
+ 'conflicts' => $conflicts,
+ 'hasConflicts' => count($conflicts) > 0,
+ ];
+}
+
+function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $teamId = null, ?string $uuid = null)
+{
+ $conflicts = [];
+
+ if (is_null($teamId)) {
+ return ['error' => 'Team ID is required.'];
+ }
+ if (is_array($domains)) {
+ $domains = collect($domains);
+ }
+
+ $domains = $domains->map(function ($domain) {
+ if (str($domain)->endsWith('/')) {
+ $domain = str($domain)->beforeLast('/');
+ }
+
+ return str($domain);
+ });
+
+ // Check applications within the same team
+ $applications = Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid', 'name', 'id']);
+ $serviceApplications = ServiceApplication::ownedByCurrentTeamAPI($teamId)->with('service:id,name')->get(['fqdn', 'uuid', 'id', 'service_id']);
+
+ if ($uuid) {
+ $applications = $applications->filter(fn ($app) => $app->uuid !== $uuid);
+ $serviceApplications = $serviceApplications->filter(fn ($app) => $app->uuid !== $uuid);
+ }
+
+ foreach ($applications as $app) {
+ if (is_null($app->fqdn)) {
+ continue;
+ }
+ $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
+ foreach ($list_of_domains as $domain) {
+ if (str($domain)->endsWith('/')) {
+ $domain = str($domain)->beforeLast('/');
+ }
+ $naked_domain = str($domain)->value();
+ if ($domains->contains($naked_domain)) {
+ $conflicts[] = [
+ 'domain' => $naked_domain,
+ 'resource_name' => $app->name,
+ 'resource_uuid' => $app->uuid,
+ 'resource_type' => 'application',
+ 'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
+ ];
+ }
+ }
+ }
+
+ foreach ($serviceApplications as $app) {
+ if (str($app->fqdn)->isEmpty()) {
+ continue;
+ }
+ $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
+ foreach ($list_of_domains as $domain) {
+ if (str($domain)->endsWith('/')) {
+ $domain = str($domain)->beforeLast('/');
+ }
+ $naked_domain = str($domain)->value();
+ if ($domains->contains($naked_domain)) {
+ $conflicts[] = [
+ 'domain' => $naked_domain,
+ 'resource_name' => $app->service->name ?? 'Unknown Service',
+ 'resource_uuid' => $app->uuid,
+ 'resource_type' => 'service',
+ 'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'",
+ ];
+ }
+ }
+ }
+
+ // Check instance-level domain
+ $settings = instanceSettings();
+ if (data_get($settings, 'fqdn')) {
+ $domain = data_get($settings, 'fqdn');
+ if (str($domain)->endsWith('/')) {
+ $domain = str($domain)->beforeLast('/');
+ }
+ $naked_domain = str($domain)->value();
+ if ($domains->contains($naked_domain)) {
+ $conflicts[] = [
+ 'domain' => $naked_domain,
+ 'resource_name' => 'Coolify Instance',
+ 'resource_uuid' => null,
+ 'resource_type' => 'instance',
+ 'message' => "Domain $naked_domain is already in use by this Coolify instance",
+ ];
+ }
+ }
+
+ return [
+ 'conflicts' => $conflicts,
+ 'hasConflicts' => count($conflicts) > 0,
+ ];
+}
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
new file mode 100644
index 000000000..d4701d251
--- /dev/null
+++ b/bootstrap/helpers/parsers.php
@@ -0,0 +1,1990 @@
+ 0 && $remaining[0] === ':') {
+ $target = substr($remaining, 1);
+ } else {
+ $target = $remaining;
+ }
+ } else {
+ $parts = explode(':', $volumeString);
+ $source = $parts[0];
+ $target = $parts[1];
+ }
+ } elseif ($colonCount === 2) {
+ // Volume with mode OR Windows path OR env var with mode
+ // Handle env var with mode first
+ if ($hasEnvVarWithDefault) {
+ // ${VAR:-default}:/path:mode
+ $source = substr($volumeString, 0, $envVarEndPos);
+ $remaining = substr($volumeString, $envVarEndPos);
+
+ if (strlen($remaining) > 0 && $remaining[0] === ':') {
+ $remaining = substr($remaining, 1);
+ $lastColon = strrpos($remaining, ':');
+
+ if ($lastColon !== false) {
+ $possibleMode = substr($remaining, $lastColon + 1);
+ $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
+
+ if (in_array($possibleMode, $validModes)) {
+ $mode = $possibleMode;
+ $target = substr($remaining, 0, $lastColon);
+ } else {
+ $target = $remaining;
+ }
+ } else {
+ $target = $remaining;
+ }
+ }
+ } elseif (preg_match('/^[A-Za-z]:/', $volumeString)) {
+ // Windows path as source (C:/, D:/, etc.)
+ // Find the second colon which is the real separator
+ $secondColon = strpos($volumeString, ':', 2);
+ if ($secondColon !== false) {
+ $source = substr($volumeString, 0, $secondColon);
+ $target = substr($volumeString, $secondColon + 1);
+ } else {
+ // Malformed, treat as is
+ $source = $volumeString;
+ $target = $volumeString;
+ }
+ } else {
+ // Not a Windows path, check for mode
+ $lastColon = strrpos($volumeString, ':');
+ $possibleMode = substr($volumeString, $lastColon + 1);
+
+ // Check if the last part is a valid Docker volume mode
+ $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
+
+ if (in_array($possibleMode, $validModes)) {
+ // It's a mode
+ // Examples: "gitea:/data:ro" or "./data:/app/data:rw"
+ $mode = $possibleMode;
+ $volumeWithoutMode = substr($volumeString, 0, $lastColon);
+ $colonPos = strpos($volumeWithoutMode, ':');
+
+ if ($colonPos !== false) {
+ $source = substr($volumeWithoutMode, 0, $colonPos);
+ $target = substr($volumeWithoutMode, $colonPos + 1);
+ } else {
+ // Shouldn't happen for valid volume strings
+ $source = $volumeWithoutMode;
+ $target = $volumeWithoutMode;
+ }
+ } else {
+ // The last colon is part of the path
+ // For now, treat the first occurrence of : as the separator
+ $firstColon = strpos($volumeString, ':');
+ $source = substr($volumeString, 0, $firstColon);
+ $target = substr($volumeString, $firstColon + 1);
+ }
+ }
+ } else {
+ // More than 2 colons - likely Windows paths or complex cases
+ // Use a heuristic: find the most likely separator colon
+ // Look for patterns like "C:" at the beginning (Windows drive)
+ if (preg_match('/^[A-Za-z]:/', $volumeString)) {
+ // Windows path as source
+ // Find the next colon after the drive letter
+ $secondColon = strpos($volumeString, ':', 2);
+ if ($secondColon !== false) {
+ $source = substr($volumeString, 0, $secondColon);
+ $remaining = substr($volumeString, $secondColon + 1);
+
+ // Check if there's a mode at the end
+ $lastColon = strrpos($remaining, ':');
+ if ($lastColon !== false) {
+ $possibleMode = substr($remaining, $lastColon + 1);
+ $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
+
+ if (in_array($possibleMode, $validModes)) {
+ $mode = $possibleMode;
+ $target = substr($remaining, 0, $lastColon);
+ } else {
+ $target = $remaining;
+ }
+ } else {
+ $target = $remaining;
+ }
+ } else {
+ // Malformed, treat as is
+ $source = $volumeString;
+ $target = $volumeString;
+ }
+ } else {
+ // Try to parse normally, treating first : as separator
+ $firstColon = strpos($volumeString, ':');
+ $source = substr($volumeString, 0, $firstColon);
+ $remaining = substr($volumeString, $firstColon + 1);
+
+ // Check for mode at the end
+ $lastColon = strrpos($remaining, ':');
+ if ($lastColon !== false) {
+ $possibleMode = substr($remaining, $lastColon + 1);
+ $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
+
+ if (in_array($possibleMode, $validModes)) {
+ $mode = $possibleMode;
+ $target = substr($remaining, 0, $lastColon);
+ } else {
+ $target = $remaining;
+ }
+ } else {
+ $target = $remaining;
+ }
+ }
+ }
+
+ // Handle environment variable expansion in source
+ // Example: ${VOLUME_DB_PATH:-db} should extract default value if present
+ if ($source && preg_match('/^\$\{([^}]+)\}$/', $source, $matches)) {
+ $varContent = $matches[1];
+
+ // Check if there's a default value with :-
+ if (strpos($varContent, ':-') !== false) {
+ $parts = explode(':-', $varContent, 2);
+ $varName = $parts[0];
+ $defaultValue = isset($parts[1]) ? $parts[1] : '';
+
+ // If there's a non-empty default value, use it for source
+ if ($defaultValue !== '') {
+ $source = $defaultValue;
+ } else {
+ // Empty default value, keep the variable reference for env resolution
+ $source = '${'.$varName.'}';
+ }
+ }
+ // Otherwise keep the variable as-is for later expansion (no default value)
+ }
+
+ return [
+ 'source' => $source !== null ? str($source) : null,
+ 'target' => $target !== null ? str($target) : null,
+ 'mode' => $mode !== null ? str($mode) : null,
+ ];
+}
+
+function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null): Collection
+{
+ $uuid = data_get($resource, 'uuid');
+ $compose = data_get($resource, 'docker_compose_raw');
+ if (! $compose) {
+ return collect([]);
+ }
+
+ $pullRequestId = $pull_request_id;
+ $isPullRequest = $pullRequestId == 0 ? false : true;
+ $server = data_get($resource, 'destination.server');
+ $fileStorages = $resource->fileStorages();
+
+ try {
+ $yaml = Yaml::parse($compose);
+ } catch (\Exception) {
+ return collect([]);
+ }
+ $services = data_get($yaml, 'services', collect([]));
+ $topLevel = collect([
+ 'volumes' => collect(data_get($yaml, 'volumes', [])),
+ 'networks' => collect(data_get($yaml, 'networks', [])),
+ 'configs' => collect(data_get($yaml, 'configs', [])),
+ 'secrets' => collect(data_get($yaml, 'secrets', [])),
+ ]);
+ // If there are predefined volumes, make sure they are not null
+ if ($topLevel->get('volumes')->count() > 0) {
+ $temp = collect([]);
+ foreach ($topLevel['volumes'] as $volumeName => $volume) {
+ if (is_null($volume)) {
+ continue;
+ }
+ $temp->put($volumeName, $volume);
+ }
+ $topLevel['volumes'] = $temp;
+ }
+ // Get the base docker network
+ $baseNetwork = collect([$uuid]);
+ if ($isPullRequest) {
+ $baseNetwork = collect(["{$uuid}-{$pullRequestId}"]);
+ }
+
+ $parsedServices = collect([]);
+
+ $allMagicEnvironments = collect([]);
+ foreach ($services as $serviceName => $service) {
+ $magicEnvironments = collect([]);
+ $image = data_get_str($service, 'image');
+ $environment = collect(data_get($service, 'environment', []));
+ $buildArgs = collect(data_get($service, 'build.args', []));
+ $environment = $environment->merge($buildArgs);
+
+ $environment = collect(data_get($service, 'environment', []));
+ $buildArgs = collect(data_get($service, 'build.args', []));
+ $environment = $environment->merge($buildArgs);
+
+ // convert environment variables to one format
+ $environment = convertToKeyValueCollection($environment);
+
+ // Add Coolify defined environments
+ $allEnvironments = $resource->environment_variables()->get(['key', 'value']);
+
+ $allEnvironments = $allEnvironments->mapWithKeys(function ($item) {
+ return [$item['key'] => $item['value']];
+ });
+ // filter and add magic environments
+ foreach ($environment as $key => $value) {
+ // Get all SERVICE_ variables from keys and values
+ $key = str($key);
+ $value = str($value);
+ $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
+ preg_match_all($regex, $value, $valueMatches);
+ if (count($valueMatches[1]) > 0) {
+ foreach ($valueMatches[1] as $match) {
+ $match = replaceVariables($match);
+ if ($match->startsWith('SERVICE_')) {
+ if ($magicEnvironments->has($match->value())) {
+ continue;
+ }
+ $magicEnvironments->put($match->value(), '');
+ }
+ }
+ }
+ // Get magic environments where we need to preset the FQDN
+ // for example SERVICE_FQDN_APP_3000 (without a value)
+ if ($key->startsWith('SERVICE_FQDN_')) {
+ // SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
+ if (substr_count(str($key)->value(), '_') === 3) {
+ $fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
+ $port = $key->afterLast('_')->value();
+ } else {
+ $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
+ $port = null;
+ }
+ $fqdn = $resource->fqdn;
+ if (blank($resource->fqdn)) {
+ $fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version);
+ }
+
+ if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
+ $path = $value->value();
+ if ($path !== '/') {
+ $fqdn = "$fqdn$path";
+ }
+ }
+ $fqdnWithPort = $fqdn;
+ if ($port) {
+ $fqdnWithPort = "$fqdn:$port";
+ }
+ if (is_null($resource->fqdn)) {
+ data_forget($resource, 'environment_variables');
+ data_forget($resource, 'environment_variables_preview');
+ $resource->fqdn = $fqdnWithPort;
+ $resource->save();
+ }
+
+ if (substr_count(str($key)->value(), '_') === 2) {
+ $resource->environment_variables()->updateOrCreate([
+ 'key' => $key->value(),
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $fqdn,
+ 'is_preview' => false,
+ ]);
+ }
+ if (substr_count(str($key)->value(), '_') === 3) {
+
+ $newKey = str($key)->beforeLast('_');
+ $resource->environment_variables()->updateOrCreate([
+ 'key' => $newKey->value(),
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $fqdn,
+ 'is_preview' => false,
+ ]);
+ }
+ }
+ }
+
+ $allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments);
+ if ($magicEnvironments->count() > 0) {
+ // Generate Coolify environment variables
+ foreach ($magicEnvironments as $key => $value) {
+ $key = str($key);
+ $value = replaceVariables($value);
+ $command = parseCommandFromMagicEnvVariable($key);
+ if ($command->value() === 'FQDN') {
+ $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
+ $originalFqdnFor = str($fqdnFor)->replace('_', '-');
+ if (str($fqdnFor)->contains('-')) {
+ $fqdnFor = str($fqdnFor)->replace('-', '_')->replace('.', '_');
+ }
+ // Generated FQDN & URL
+ $fqdn = generateFqdn(server: $server, random: "$originalFqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
+ $url = generateUrl(server: $server, random: "$originalFqdnFor-$uuid");
+ $resource->environment_variables()->firstOrCreate([
+ 'key' => $key->value(),
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $fqdn,
+ '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,
+ ]);
+ }
+ 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') {
+ $urlFor = $key->after('SERVICE_URL_')->lower()->value();
+ $originalUrlFor = str($urlFor)->replace('_', '-');
+ if (str($urlFor)->contains('-')) {
+ $urlFor = str($urlFor)->replace('-', '_')->replace('.', '_');
+ }
+ $url = generateUrl(server: $server, random: "$originalUrlFor-$uuid");
+ $resource->environment_variables()->firstOrCreate([
+ 'key' => $key->value(),
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $url,
+ '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,
+ ]);
+ }
+ if (is_null($domainExists)) {
+ $domains->put((string) $urlFor, [
+ 'domain' => $url,
+ ]);
+ $resource->docker_compose_domains = $domains->toJson();
+ $resource->save();
+ }
+ }
+ } else {
+ $value = generateEnvValue($command, $resource);
+ $resource->environment_variables()->firstOrCreate([
+ 'key' => $key->value(),
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $value,
+ 'is_preview' => false,
+ ]);
+ }
+ }
+ }
+ }
+
+ // generate SERVICE_NAME variables for docker compose services
+ $serviceNameEnvironments = collect([]);
+ if ($resource->build_pack === 'dockercompose') {
+ $serviceNameEnvironments = generateDockerComposeServiceName($services, $pullRequestId);
+ }
+
+ // Parse the rest of the services
+ foreach ($services as $serviceName => $service) {
+ $image = data_get_str($service, 'image');
+ $restart = data_get_str($service, 'restart', RESTART_MODE);
+ $logging = data_get($service, 'logging');
+
+ if ($server->isLogDrainEnabled()) {
+ if ($resource->isLogDrainEnabled()) {
+ $logging = generate_fluentd_configuration();
+ }
+ }
+ $volumes = collect(data_get($service, 'volumes', []));
+ $networks = collect(data_get($service, 'networks', []));
+ $use_network_mode = data_get($service, 'network_mode') !== null;
+ $depends_on = collect(data_get($service, 'depends_on', []));
+ $labels = collect(data_get($service, 'labels', []));
+ if ($labels->count() > 0) {
+ if (isAssociativeArray($labels)) {
+ $newLabels = collect([]);
+ $labels->each(function ($value, $key) use ($newLabels) {
+ $newLabels->push("$key=$value");
+ });
+ $labels = $newLabels;
+ }
+ }
+ $environment = collect(data_get($service, 'environment', []));
+ $ports = collect(data_get($service, 'ports', []));
+ $buildArgs = collect(data_get($service, 'build.args', []));
+ $environment = $environment->merge($buildArgs);
+
+ $environment = convertToKeyValueCollection($environment);
+ $coolifyEnvironments = collect([]);
+
+ $isDatabase = isDatabaseImage($image, $service);
+ $volumesParsed = collect([]);
+
+ $baseName = generateApplicationContainerName(
+ application: $resource,
+ pull_request_id: $pullRequestId
+ );
+ $containerName = "$serviceName-$baseName";
+ $predefinedPort = null;
+
+ $originalResource = $resource;
+
+ if ($volumes->count() > 0) {
+ foreach ($volumes as $index => $volume) {
+ $type = null;
+ $source = null;
+ $target = null;
+ $content = null;
+ $isDirectory = false;
+ if (is_string($volume)) {
+ $parsed = parseDockerVolumeString($volume);
+ $source = $parsed['source'];
+ $target = $parsed['target'];
+ // Mode is available in $parsed['mode'] if needed
+ $foundConfig = $fileStorages->whereMountPath($target)->first();
+ if (sourceIsLocal($source)) {
+ $type = str('bind');
+ if ($foundConfig) {
+ $contentNotNull_temp = data_get($foundConfig, 'content');
+ if ($contentNotNull_temp) {
+ $content = $contentNotNull_temp;
+ }
+ $isDirectory = data_get($foundConfig, 'is_directory');
+ } else {
+ // By default, we cannot determine if the bind is a directory or not, so we set it to directory
+ $isDirectory = true;
+ }
+ } else {
+ $type = str('volume');
+ }
+ } elseif (is_array($volume)) {
+ $type = data_get_str($volume, 'type');
+ $source = data_get_str($volume, 'source');
+ $target = data_get_str($volume, 'target');
+ $content = data_get($volume, 'content');
+ $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
+
+ $foundConfig = $fileStorages->whereMountPath($target)->first();
+ if ($foundConfig) {
+ $contentNotNull_temp = data_get($foundConfig, 'content');
+ if ($contentNotNull_temp) {
+ $content = $contentNotNull_temp;
+ }
+ $isDirectory = data_get($foundConfig, 'is_directory');
+ } else {
+ // if isDirectory is not set (or false) & content is also not set, we assume it is a directory
+ if ((is_null($isDirectory) || ! $isDirectory) && is_null($content)) {
+ $isDirectory = true;
+ }
+ }
+ }
+ if ($type->value() === 'bind') {
+ if ($source->value() === '/var/run/docker.sock') {
+ $volume = $source->value().':'.$target->value();
+ if (isset($parsed['mode']) && $parsed['mode']) {
+ $volume .= ':'.$parsed['mode']->value();
+ }
+ } elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') {
+ $volume = $source->value().':'.$target->value();
+ if (isset($parsed['mode']) && $parsed['mode']) {
+ $volume .= ':'.$parsed['mode']->value();
+ }
+ } else {
+ if ((int) $resource->compose_parsing_version >= 4) {
+ $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
+ } else {
+ $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
+ }
+ $source = replaceLocalSource($source, $mainDirectory);
+ if ($isPullRequest) {
+ $source = addPreviewDeploymentSuffix($source, $pull_request_id);
+ }
+ LocalFileVolume::updateOrCreate(
+ [
+ 'mount_path' => $target,
+ 'resource_id' => $originalResource->id,
+ 'resource_type' => get_class($originalResource),
+ ],
+ [
+ 'fs_path' => $source,
+ 'mount_path' => $target,
+ 'content' => $content,
+ 'is_directory' => $isDirectory,
+ 'resource_id' => $originalResource->id,
+ 'resource_type' => get_class($originalResource),
+ ]
+ );
+ if (isDev()) {
+ if ((int) $resource->compose_parsing_version >= 4) {
+ $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid);
+ } else {
+ $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid);
+ }
+ }
+ $volume = "$source:$target";
+ if (isset($parsed['mode']) && $parsed['mode']) {
+ $volume .= ':'.$parsed['mode']->value();
+ }
+ }
+ } elseif ($type->value() === 'volume') {
+ if ($topLevel->get('volumes')->has($source->value())) {
+ $temp = $topLevel->get('volumes')->get($source->value());
+ if (data_get($temp, 'driver_opts.type') === 'cifs') {
+ continue;
+ }
+ if (data_get($temp, 'driver_opts.type') === 'nfs') {
+ continue;
+ }
+ }
+ $slugWithoutUuid = Str::slug($source, '-');
+ $name = "{$uuid}_{$slugWithoutUuid}";
+
+ if ($isPullRequest) {
+ $name = addPreviewDeploymentSuffix($name, $pull_request_id);
+ }
+ if (is_string($volume)) {
+ $parsed = parseDockerVolumeString($volume);
+ $source = $parsed['source'];
+ $target = $parsed['target'];
+ $source = $name;
+ $volume = "$source:$target";
+ if (isset($parsed['mode']) && $parsed['mode']) {
+ $volume .= ':'.$parsed['mode']->value();
+ }
+ } elseif (is_array($volume)) {
+ data_set($volume, 'source', $name);
+ }
+ $topLevel->get('volumes')->put($name, [
+ 'name' => $name,
+ ]);
+ LocalPersistentVolume::updateOrCreate(
+ [
+ 'name' => $name,
+ 'resource_id' => $originalResource->id,
+ 'resource_type' => get_class($originalResource),
+ ],
+ [
+ 'name' => $name,
+ 'mount_path' => $target,
+ 'resource_id' => $originalResource->id,
+ 'resource_type' => get_class($originalResource),
+ ]
+ );
+ }
+ dispatch(new ServerFilesFromServerJob($originalResource));
+ $volumesParsed->put($index, $volume);
+ }
+ }
+
+ if ($depends_on?->count() > 0) {
+ if ($isPullRequest) {
+ $newDependsOn = collect([]);
+ $depends_on->each(function ($dependency, $condition) use ($pullRequestId, $newDependsOn) {
+ if (is_numeric($condition)) {
+ $dependency = addPreviewDeploymentSuffix($dependency, $pullRequestId);
+
+ $newDependsOn->put($condition, $dependency);
+ } else {
+ $condition = addPreviewDeploymentSuffix($condition, $pullRequestId);
+ $newDependsOn->put($condition, $dependency);
+ }
+ });
+ $depends_on = $newDependsOn;
+ }
+ }
+ if (! $use_network_mode) {
+ if ($topLevel->get('networks')?->count() > 0) {
+ foreach ($topLevel->get('networks') as $networkName => $network) {
+ if ($networkName === 'default') {
+ continue;
+ }
+ // ignore aliases
+ if ($network['aliases'] ?? false) {
+ continue;
+ }
+ $networkExists = $networks->contains(function ($value, $key) use ($networkName) {
+ return $value == $networkName || $key == $networkName;
+ });
+ if (! $networkExists) {
+ $networks->put($networkName, null);
+ }
+ }
+ }
+ $baseNetworkExists = $networks->contains(function ($value, $_) use ($baseNetwork) {
+ return $value == $baseNetwork;
+ });
+ if (! $baseNetworkExists) {
+ foreach ($baseNetwork as $network) {
+ $topLevel->get('networks')->put($network, [
+ 'name' => $network,
+ 'external' => true,
+ ]);
+ }
+ }
+ }
+
+ // Collect/create/update ports
+ $collectedPorts = collect([]);
+ if ($ports->count() > 0) {
+ foreach ($ports as $sport) {
+ if (is_string($sport) || is_numeric($sport)) {
+ $collectedPorts->push($sport);
+ }
+ if (is_array($sport)) {
+ $target = data_get($sport, 'target');
+ $published = data_get($sport, 'published');
+ $protocol = data_get($sport, 'protocol');
+ $collectedPorts->push("$target:$published/$protocol");
+ }
+ }
+ }
+
+ $networks_temp = collect();
+
+ if (! $use_network_mode) {
+ foreach ($networks as $key => $network) {
+ if (gettype($network) === 'string') {
+ // networks:
+ // - appwrite
+ $networks_temp->put($network, null);
+ } elseif (gettype($network) === 'array') {
+ // networks:
+ // default:
+ // ipv4_address: 192.168.203.254
+ $networks_temp->put($key, $network);
+ }
+ }
+ foreach ($baseNetwork as $key => $network) {
+ $networks_temp->put($network, null);
+ }
+
+ if (data_get($resource, 'settings.connect_to_docker_network')) {
+ $network = $resource->destination->network;
+ $networks_temp->put($network, null);
+ $topLevel->get('networks')->put($network, [
+ 'name' => $network,
+ 'external' => true,
+ ]);
+ }
+ }
+
+ $normalEnvironments = $environment->diffKeys($allMagicEnvironments);
+ $normalEnvironments = $normalEnvironments->filter(function ($value, $key) {
+ return ! str($value)->startsWith('SERVICE_');
+ });
+ foreach ($normalEnvironments as $key => $value) {
+ $key = str($key);
+ $value = str($value);
+ $originalValue = $value;
+ $parsedValue = replaceVariables($value);
+ if ($value->startsWith('$SERVICE_')) {
+ $resource->environment_variables()->firstOrCreate([
+ 'key' => $key,
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $value,
+ 'is_preview' => false,
+ ]);
+
+ continue;
+ }
+ if (! $value->startsWith('$')) {
+ continue;
+ }
+ if ($key->value() === $parsedValue->value()) {
+ $value = null;
+ $resource->environment_variables()->firstOrCreate([
+ 'key' => $key,
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $value,
+ 'is_preview' => false,
+ ]);
+ } else {
+ if ($value->startsWith('$')) {
+ $isRequired = false;
+ if ($value->contains(':-')) {
+ $value = replaceVariables($value);
+ $key = $value->before(':');
+ $value = $value->after(':-');
+ } elseif ($value->contains('-')) {
+ $value = replaceVariables($value);
+
+ $key = $value->before('-');
+ $value = $value->after('-');
+ } elseif ($value->contains(':?')) {
+ $value = replaceVariables($value);
+
+ $key = $value->before(':');
+ $value = $value->after(':?');
+ $isRequired = true;
+ } elseif ($value->contains('?')) {
+ $value = replaceVariables($value);
+
+ $key = $value->before('?');
+ $value = $value->after('?');
+ $isRequired = true;
+ }
+ if ($originalValue->value() === $value->value()) {
+ // This means the variable does not have a default value, so it needs to be created in Coolify
+ $parsedKeyValue = replaceVariables($value);
+ $resource->environment_variables()->firstOrCreate([
+ 'key' => $parsedKeyValue,
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'is_preview' => false,
+ 'is_required' => $isRequired,
+ ]);
+ // Add the variable to the environment so it will be shown in the deployable compose file
+ $environment[$parsedKeyValue->value()] = $value;
+
+ continue;
+ }
+ $resource->environment_variables()->firstOrCreate([
+ 'key' => $key,
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $value,
+ 'is_preview' => false,
+ 'is_required' => $isRequired,
+ ]);
+ }
+ }
+ }
+ $branch = $originalResource->git_branch;
+ if ($pullRequestId !== 0) {
+ $branch = "pull/{$pullRequestId}/head";
+ }
+ if ($originalResource->environment_variables->where('key', 'COOLIFY_BRANCH')->isEmpty()) {
+ $coolifyEnvironments->put('COOLIFY_BRANCH', "\"{$branch}\"");
+ }
+
+ // Add COOLIFY_RESOURCE_UUID to environment
+ if ($resource->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
+ $coolifyEnvironments->put('COOLIFY_RESOURCE_UUID', "{$resource->uuid}");
+ }
+
+ // Add COOLIFY_CONTAINER_NAME to environment
+ if ($resource->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
+ $coolifyEnvironments->put('COOLIFY_CONTAINER_NAME', "{$containerName}");
+ }
+
+ if ($isPullRequest) {
+ $preview = $resource->previews()->find($preview_id);
+ $domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))) ?? collect([]);
+ } else {
+ $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
+ }
+
+ // Only process domains for dockercompose applications to prevent SERVICE variable recreation
+ if ($resource->build_pack !== 'dockercompose') {
+ $domains = collect([]);
+ }
+ $changedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
+ $fqdns = data_get($domains, "$changedServiceName.domain");
+ // Generate SERVICE_FQDN & SERVICE_URL for dockercompose
+ if ($resource->build_pack === 'dockercompose') {
+ foreach ($domains as $forServiceName => $domain) {
+ $parsedDomain = data_get($domain, 'domain');
+ $serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
+
+ if (filled($parsedDomain)) {
+ $parsedDomain = str($parsedDomain)->explode(',')->first();
+ $coolifyUrl = Url::fromString($parsedDomain);
+ $coolifyScheme = $coolifyUrl->getScheme();
+ $coolifyFqdn = $coolifyUrl->getHost();
+ $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
+ $coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyUrl->__toString());
+ $coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyFqdn);
+ $resource->environment_variables()->updateOrCreate([
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $resource->id,
+ 'key' => 'SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
+ ], [
+ 'value' => $coolifyUrl->__toString(),
+ 'is_preview' => false,
+ ]);
+ $resource->environment_variables()->updateOrCreate([
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $resource->id,
+ 'key' => 'SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
+ ], [
+ 'value' => $coolifyFqdn,
+ 'is_preview' => false,
+ ]);
+ } else {
+ $resource->environment_variables()->where('resourceable_type', Application::class)
+ ->where('resourceable_id', $resource->id)
+ ->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%")
+ ->update([
+ 'value' => null,
+ ]);
+ $resource->environment_variables()->where('resourceable_type', Application::class)
+ ->where('resourceable_id', $resource->id)
+ ->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%")
+ ->update([
+ 'value' => null,
+ ]);
+ }
+ }
+ }
+ // If the domain is set, we need to generate the FQDNs for the preview
+ if (filled($fqdns)) {
+ $fqdns = str($fqdns)->explode(',');
+ if ($isPullRequest) {
+ $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");
+ if ($found_fqdn) {
+ $fqdns = collect($found_fqdn);
+ } else {
+ $fqdns = collect([]);
+ }
+ } else {
+ $fqdns = $fqdns->map(function ($fqdn) use ($pullRequestId, $resource) {
+ $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pullRequestId);
+ $url = Url::fromString($fqdn);
+ $template = $resource->preview_url_template;
+ $host = $url->getHost();
+ $schema = $url->getScheme();
+ $random = new Cuid2;
+ $preview_fqdn = str_replace('{{random}}', $random, $template);
+ $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
+ $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
+ $preview_fqdn = "$schema://$preview_fqdn";
+ $preview->fqdn = $preview_fqdn;
+ $preview->save();
+
+ return $preview_fqdn;
+ });
+ }
+ }
+ }
+ $defaultLabels = defaultLabels(
+ id: $resource->id,
+ name: $containerName,
+ projectName: $resource->project()->name,
+ resourceName: $resource->name,
+ pull_request_id: $pullRequestId,
+ type: 'application',
+ environment: $resource->environment->name,
+ );
+
+ $isDatabase = isDatabaseImage($image, $service);
+ // Add COOLIFY_FQDN & COOLIFY_URL to environment
+ if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
+ $fqdnsWithoutPort = $fqdns->map(function ($fqdn) {
+ return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://'));
+ });
+ $coolifyEnvironments->put('COOLIFY_URL', $fqdnsWithoutPort->implode(','));
+
+ $urls = $fqdns->map(function ($fqdn) {
+ return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':');
+ });
+ $coolifyEnvironments->put('COOLIFY_FQDN', $urls->implode(','));
+ }
+ add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
+ if ($environment->count() > 0) {
+ $environment = $environment->filter(function ($value, $key) {
+ return ! str($key)->startsWith('SERVICE_FQDN_');
+ })->map(function ($value, $key) use ($resource) {
+ // if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used
+ if (str($value)->isEmpty()) {
+ if ($resource->environment_variables()->where('key', $key)->exists()) {
+ $value = $resource->environment_variables()->where('key', $key)->first()->value;
+ } else {
+ $value = null;
+ }
+ }
+
+ return $value;
+ });
+ }
+ $serviceLabels = $labels->merge($defaultLabels);
+ if ($serviceLabels->count() > 0) {
+ $isContainerLabelEscapeEnabled = data_get($resource, 'settings.is_container_label_escape_enabled');
+ if ($isContainerLabelEscapeEnabled) {
+ $serviceLabels = $serviceLabels->map(function ($value, $key) {
+ return escapeDollarSign($value);
+ });
+ }
+ }
+ if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
+ $shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;
+ $uuid = $resource->uuid;
+ $network = data_get($resource, 'destination.network');
+ if ($isPullRequest) {
+ $uuid = "{$resource->uuid}-{$pullRequestId}";
+ }
+ if ($isPullRequest) {
+ $network = "{$resource->destination->network}-{$pullRequestId}";
+ }
+ if ($shouldGenerateLabelsExactly) {
+ switch ($server->proxyType()) {
+ case ProxyTypes::TRAEFIK->value:
+ $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
+ uuid: $uuid,
+ domains: $fqdns,
+ is_force_https_enabled: true,
+ serviceLabels: $serviceLabels,
+ is_gzip_enabled: $originalResource->isGzipEnabled(),
+ is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
+ service_name: $serviceName,
+ image: $image
+ ));
+ break;
+ case ProxyTypes::CADDY->value:
+ $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
+ network: $network,
+ uuid: $uuid,
+ domains: $fqdns,
+ is_force_https_enabled: true,
+ serviceLabels: $serviceLabels,
+ is_gzip_enabled: $originalResource->isGzipEnabled(),
+ is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
+ service_name: $serviceName,
+ image: $image,
+ predefinedPort: $predefinedPort
+ ));
+ break;
+ }
+ } else {
+ $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
+ uuid: $uuid,
+ domains: $fqdns,
+ is_force_https_enabled: true,
+ serviceLabels: $serviceLabels,
+ is_gzip_enabled: $originalResource->isGzipEnabled(),
+ is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
+ service_name: $serviceName,
+ image: $image
+ ));
+ $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
+ network: $network,
+ uuid: $uuid,
+ domains: $fqdns,
+ is_force_https_enabled: true,
+ serviceLabels: $serviceLabels,
+ is_gzip_enabled: $originalResource->isGzipEnabled(),
+ is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
+ service_name: $serviceName,
+ image: $image,
+ predefinedPort: $predefinedPort
+ ));
+ }
+ }
+ data_forget($service, 'volumes.*.content');
+ data_forget($service, 'volumes.*.isDirectory');
+ data_forget($service, 'volumes.*.is_directory');
+ data_forget($service, 'exclude_from_hc');
+
+ $volumesParsed = $volumesParsed->map(function ($volume) {
+ data_forget($volume, 'content');
+ data_forget($volume, 'is_directory');
+ data_forget($volume, 'isDirectory');
+
+ return $volume;
+ });
+
+ $payload = collect($service)->merge([
+ 'container_name' => $containerName,
+ 'restart' => $restart->value(),
+ 'labels' => $serviceLabels,
+ ]);
+ if (! $use_network_mode) {
+ $payload['networks'] = $networks_temp;
+ }
+ if ($ports->count() > 0) {
+ $payload['ports'] = $ports;
+ }
+ if ($volumesParsed->count() > 0) {
+ $payload['volumes'] = $volumesParsed;
+ }
+ if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) {
+ $payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments);
+ }
+ if ($logging) {
+ $payload['logging'] = $logging;
+ }
+ if ($depends_on->count() > 0) {
+ $payload['depends_on'] = $depends_on;
+ }
+ if ($isPullRequest) {
+ $serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId);
+ }
+
+ $parsedServices->put($serviceName, $payload);
+ }
+ $topLevel->put('services', $parsedServices);
+
+ $customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets'];
+
+ $topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) {
+ return array_search($key, $customOrder);
+ });
+
+ $resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2);
+ data_forget($resource, 'environment_variables');
+ data_forget($resource, 'environment_variables_preview');
+ $resource->save();
+
+ return $topLevel;
+}
+
+function serviceParser(Service $resource): Collection
+{
+ $uuid = data_get($resource, 'uuid');
+ $compose = data_get($resource, 'docker_compose_raw');
+ if (! $compose) {
+ return collect([]);
+ }
+
+ $server = data_get($resource, 'server');
+ $allServices = get_service_templates();
+
+ try {
+ $yaml = Yaml::parse($compose);
+ } catch (\Exception) {
+ return collect([]);
+ }
+ $services = data_get($yaml, 'services', collect([]));
+ $topLevel = collect([
+ 'volumes' => collect(data_get($yaml, 'volumes', [])),
+ 'networks' => collect(data_get($yaml, 'networks', [])),
+ 'configs' => collect(data_get($yaml, 'configs', [])),
+ 'secrets' => collect(data_get($yaml, 'secrets', [])),
+ ]);
+ // If there are predefined volumes, make sure they are not null
+ if ($topLevel->get('volumes')->count() > 0) {
+ $temp = collect([]);
+ foreach ($topLevel['volumes'] as $volumeName => $volume) {
+ if (is_null($volume)) {
+ continue;
+ }
+ $temp->put($volumeName, $volume);
+ }
+ $topLevel['volumes'] = $temp;
+ }
+ // Get the base docker network
+ $baseNetwork = collect([$uuid]);
+
+ $parsedServices = collect([]);
+
+ $allMagicEnvironments = collect([]);
+ // Presave services
+ foreach ($services as $serviceName => $service) {
+ $image = data_get_str($service, 'image');
+ $isDatabase = isDatabaseImage($image, $service);
+ if ($isDatabase) {
+ $applicationFound = ServiceApplication::where('name', $serviceName)->where('image', $image)->where('service_id', $resource->id)->first();
+ if ($applicationFound) {
+ $savedService = $applicationFound;
+ } else {
+ $savedService = ServiceDatabase::firstOrCreate([
+ 'name' => $serviceName,
+ 'image' => $image,
+ 'service_id' => $resource->id,
+ ]);
+ }
+ } else {
+ $savedService = ServiceApplication::firstOrCreate([
+ 'name' => $serviceName,
+ 'image' => $image,
+ 'service_id' => $resource->id,
+ ]);
+ }
+ }
+ foreach ($services as $serviceName => $service) {
+ $predefinedPort = null;
+ $magicEnvironments = collect([]);
+ $image = data_get_str($service, 'image');
+ $environment = collect(data_get($service, 'environment', []));
+ $buildArgs = collect(data_get($service, 'build.args', []));
+ $environment = $environment->merge($buildArgs);
+ $isDatabase = isDatabaseImage($image, $service);
+
+ $containerName = "$serviceName-{$resource->uuid}";
+
+ if ($serviceName === 'registry') {
+ $tempServiceName = 'docker-registry';
+ } else {
+ $tempServiceName = $serviceName;
+ }
+ if (str(data_get($service, 'image'))->contains('glitchtip')) {
+ $tempServiceName = 'glitchtip';
+ }
+ if ($serviceName === 'supabase-kong') {
+ $tempServiceName = 'supabase';
+ }
+ $serviceDefinition = data_get($allServices, $tempServiceName);
+ $predefinedPort = data_get($serviceDefinition, 'port');
+ if ($serviceName === 'plausible') {
+ $predefinedPort = '8000';
+ }
+ if ($isDatabase) {
+ $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
+ if ($applicationFound) {
+ $savedService = $applicationFound;
+ } else {
+ $savedService = ServiceDatabase::firstOrCreate([
+ 'name' => $serviceName,
+ 'service_id' => $resource->id,
+ ]);
+ }
+ } else {
+ $savedService = ServiceApplication::firstOrCreate([
+ 'name' => $serviceName,
+ 'service_id' => $resource->id,
+ ], [
+ 'is_gzip_enabled' => true,
+ ]);
+ }
+ // Check if image changed
+ if ($savedService->image !== $image) {
+ $savedService->image = $image;
+ $savedService->save();
+ }
+ // Pocketbase does not need gzip for SSE.
+ if (str($savedService->image)->contains('pocketbase') && $savedService->is_gzip_enabled) {
+ $savedService->is_gzip_enabled = false;
+ $savedService->save();
+ }
+
+ $environment = collect(data_get($service, 'environment', []));
+ $buildArgs = collect(data_get($service, 'build.args', []));
+ $environment = $environment->merge($buildArgs);
+
+ // convert environment variables to one format
+ $environment = convertToKeyValueCollection($environment);
+
+ // Add Coolify defined environments
+ $allEnvironments = $resource->environment_variables()->get(['key', 'value']);
+
+ $allEnvironments = $allEnvironments->mapWithKeys(function ($item) {
+ return [$item['key'] => $item['value']];
+ });
+ // filter and add magic environments
+ foreach ($environment as $key => $value) {
+ // Get all SERVICE_ variables from keys and values
+ $key = str($key);
+ $value = str($value);
+ $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
+ preg_match_all($regex, $value, $valueMatches);
+ if (count($valueMatches[1]) > 0) {
+ foreach ($valueMatches[1] as $match) {
+ $match = replaceVariables($match);
+ if ($match->startsWith('SERVICE_')) {
+ if ($magicEnvironments->has($match->value())) {
+ continue;
+ }
+ $magicEnvironments->put($match->value(), '');
+ }
+ }
+ }
+ // Get magic environments where we need to preset the FQDN / URL
+ if ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_')) {
+ // SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
+ if (substr_count(str($key)->value(), '_') === 3) {
+ if ($key->startsWith('SERVICE_FQDN_')) {
+ $urlFor = null;
+ $fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
+ }
+ if ($key->startsWith('SERVICE_URL_')) {
+ $fqdnFor = null;
+ $urlFor = $key->after('SERVICE_URL_')->beforeLast('_')->lower()->value();
+ }
+ $port = $key->afterLast('_')->value();
+ } else {
+ if ($key->startsWith('SERVICE_FQDN_')) {
+ $urlFor = null;
+ $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
+ }
+ if ($key->startsWith('SERVICE_URL_')) {
+ $fqdnFor = null;
+ $urlFor = $key->after('SERVICE_URL_')->lower()->value();
+ }
+ $port = null;
+ }
+ if (blank($savedService->fqdn)) {
+ if ($fqdnFor) {
+ $fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
+ } else {
+ $fqdn = generateFqdn(server: $server, random: "{$savedService->name}-$uuid", parserVersion: $resource->compose_parsing_version);
+ }
+ if ($urlFor) {
+ $url = generateUrl($server, "$urlFor-$uuid");
+ } else {
+ $url = generateUrl($server, "{$savedService->name}-$uuid");
+ }
+ } else {
+ $fqdn = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
+ $url = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
+ }
+
+ if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
+ $path = $value->value();
+ if ($path !== '/') {
+ $fqdn = "$fqdn$path";
+ $url = "$url$path";
+ }
+ }
+ $fqdnWithPort = $fqdn;
+ $urlWithPort = $url;
+ if ($fqdn && $port) {
+ $fqdnWithPort = "$fqdn:$port";
+ }
+ if ($url && $port) {
+ $urlWithPort = "$url:$port";
+ }
+ if (is_null($savedService->fqdn)) {
+ if ((int) $resource->compose_parsing_version >= 5 && version_compare(config('constants.coolify.version'), '4.0.0-beta.420.7', '>=')) {
+ if ($fqdnFor) {
+ $savedService->fqdn = $fqdnWithPort;
+ }
+ if ($urlFor) {
+ $savedService->fqdn = $urlWithPort;
+ }
+ } else {
+ $savedService->fqdn = $fqdnWithPort;
+ }
+ $savedService->save();
+ }
+ if (substr_count(str($key)->value(), '_') === 2) {
+ $resource->environment_variables()->updateOrCreate([
+ 'key' => $key->value(),
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $fqdn,
+ 'is_preview' => false,
+ ]);
+ $resource->environment_variables()->updateOrCreate([
+ 'key' => $key->value(),
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $url,
+ 'is_preview' => false,
+ ]);
+ }
+ if (substr_count(str($key)->value(), '_') === 3) {
+ $newKey = str($key)->beforeLast('_');
+ $resource->environment_variables()->updateOrCreate([
+ 'key' => $newKey->value(),
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $fqdn,
+ 'is_preview' => false,
+ ]);
+ $resource->environment_variables()->updateOrCreate([
+ 'key' => $newKey->value(),
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $url,
+ 'is_preview' => false,
+ ]);
+ }
+ }
+ }
+ $allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments);
+ if ($magicEnvironments->count() > 0) {
+ foreach ($magicEnvironments as $key => $value) {
+ $key = str($key);
+ $value = replaceVariables($value);
+ $command = parseCommandFromMagicEnvVariable($key);
+ if ($command->value() === 'FQDN') {
+ $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
+ $fqdn = generateFqdn(server: $server, random: str($fqdnFor)->replace('_', '-')->value()."-$uuid", parserVersion: $resource->compose_parsing_version);
+ $url = generateUrl(server: $server, random: str($fqdnFor)->replace('_', '-')->value()."-$uuid");
+
+ $envExists = $resource->environment_variables()->where('key', $key->value())->first();
+ $serviceExists = ServiceApplication::where('name', str($fqdnFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
+ if (! $envExists && (data_get($serviceExists, 'name') === str($fqdnFor)->replace('_', '-')->value())) {
+ // Save URL otherwise it won't work.
+ $serviceExists->fqdn = $url;
+ $serviceExists->save();
+ }
+ $resource->environment_variables()->firstOrCreate([
+ 'key' => $key->value(),
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $fqdn,
+ 'is_preview' => false,
+ ]);
+
+ } elseif ($command->value() === 'URL') {
+ $urlFor = $key->after('SERVICE_URL_')->lower()->value();
+ $url = generateUrl(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid");
+
+ $envExists = $resource->environment_variables()->where('key', $key->value())->first();
+ $serviceExists = ServiceApplication::where('name', str($urlFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
+ if (! $envExists && (data_get($serviceExists, 'name') === str($urlFor)->replace('_', '-')->value())) {
+ $serviceExists->fqdn = $url;
+ $serviceExists->save();
+ }
+ $resource->environment_variables()->firstOrCreate([
+ 'key' => $key->value(),
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $url,
+ 'is_preview' => false,
+ ]);
+
+ } else {
+ $value = generateEnvValue($command, $resource);
+ $resource->environment_variables()->firstOrCreate([
+ 'key' => $key->value(),
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $value,
+ 'is_preview' => false,
+ ]);
+ }
+ }
+ }
+ }
+
+ $serviceAppsLogDrainEnabledMap = $resource->applications()->get()->keyBy('name')->map(function ($app) {
+ return $app->isLogDrainEnabled();
+ });
+
+ // Parse the rest of the services
+ foreach ($services as $serviceName => $service) {
+ $image = data_get_str($service, 'image');
+ $restart = data_get_str($service, 'restart', RESTART_MODE);
+ $logging = data_get($service, 'logging');
+
+ if ($server->isLogDrainEnabled()) {
+ if ($serviceAppsLogDrainEnabledMap->get($serviceName)) {
+ $logging = generate_fluentd_configuration();
+ }
+ }
+ $volumes = collect(data_get($service, 'volumes', []));
+ $networks = collect(data_get($service, 'networks', []));
+ $use_network_mode = data_get($service, 'network_mode') !== null;
+ $depends_on = collect(data_get($service, 'depends_on', []));
+ $labels = collect(data_get($service, 'labels', []));
+ if ($labels->count() > 0) {
+ if (isAssociativeArray($labels)) {
+ $newLabels = collect([]);
+ $labels->each(function ($value, $key) use ($newLabels) {
+ $newLabels->push("$key=$value");
+ });
+ $labels = $newLabels;
+ }
+ }
+ $environment = collect(data_get($service, 'environment', []));
+ $ports = collect(data_get($service, 'ports', []));
+ $buildArgs = collect(data_get($service, 'build.args', []));
+ $environment = $environment->merge($buildArgs);
+
+ $environment = convertToKeyValueCollection($environment);
+ $coolifyEnvironments = collect([]);
+
+ $isDatabase = isDatabaseImage($image, $service);
+ $volumesParsed = collect([]);
+
+ $containerName = "$serviceName-{$resource->uuid}";
+
+ if ($serviceName === 'registry') {
+ $tempServiceName = 'docker-registry';
+ } else {
+ $tempServiceName = $serviceName;
+ }
+ if (str(data_get($service, 'image'))->contains('glitchtip')) {
+ $tempServiceName = 'glitchtip';
+ }
+ if ($serviceName === 'supabase-kong') {
+ $tempServiceName = 'supabase';
+ }
+ $serviceDefinition = data_get($allServices, $tempServiceName);
+ $predefinedPort = data_get($serviceDefinition, 'port');
+ if ($serviceName === 'plausible') {
+ $predefinedPort = '8000';
+ }
+
+ if ($isDatabase) {
+ $applicationFound = ServiceApplication::where('name', $serviceName)->where('image', $image)->where('service_id', $resource->id)->first();
+ if ($applicationFound) {
+ $savedService = $applicationFound;
+ } else {
+ $savedService = ServiceDatabase::firstOrCreate([
+ 'name' => $serviceName,
+ 'image' => $image,
+ 'service_id' => $resource->id,
+ ]);
+ }
+ } else {
+ $savedService = ServiceApplication::firstOrCreate([
+ 'name' => $serviceName,
+ 'image' => $image,
+ 'service_id' => $resource->id,
+ ]);
+ }
+ $fileStorages = $savedService->fileStorages();
+ if ($savedService->image !== $image) {
+ $savedService->image = $image;
+ $savedService->save();
+ }
+
+ $originalResource = $savedService;
+
+ if ($volumes->count() > 0) {
+ foreach ($volumes as $index => $volume) {
+ $type = null;
+ $source = null;
+ $target = null;
+ $content = null;
+ $isDirectory = false;
+ if (is_string($volume)) {
+ $parsed = parseDockerVolumeString($volume);
+ $source = $parsed['source'];
+ $target = $parsed['target'];
+ // Mode is available in $parsed['mode'] if needed
+ $foundConfig = $fileStorages->whereMountPath($target)->first();
+ if (sourceIsLocal($source)) {
+ $type = str('bind');
+ if ($foundConfig) {
+ $contentNotNull_temp = data_get($foundConfig, 'content');
+ if ($contentNotNull_temp) {
+ $content = $contentNotNull_temp;
+ }
+ $isDirectory = data_get($foundConfig, 'is_directory');
+ } else {
+ // By default, we cannot determine if the bind is a directory or not, so we set it to directory
+ $isDirectory = true;
+ }
+ } else {
+ $type = str('volume');
+ }
+ } elseif (is_array($volume)) {
+ $type = data_get_str($volume, 'type');
+ $source = data_get_str($volume, 'source');
+ $target = data_get_str($volume, 'target');
+ $content = data_get($volume, 'content');
+ $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
+
+ $foundConfig = $fileStorages->whereMountPath($target)->first();
+ if ($foundConfig) {
+ $contentNotNull_temp = data_get($foundConfig, 'content');
+ if ($contentNotNull_temp) {
+ $content = $contentNotNull_temp;
+ }
+ $isDirectory = data_get($foundConfig, 'is_directory');
+ } else {
+ // if isDirectory is not set (or false) & content is also not set, we assume it is a directory
+ if ((is_null($isDirectory) || ! $isDirectory) && is_null($content)) {
+ $isDirectory = true;
+ }
+ }
+ }
+ if ($type->value() === 'bind') {
+ if ($source->value() === '/var/run/docker.sock') {
+ $volume = $source->value().':'.$target->value();
+ if (isset($parsed['mode']) && $parsed['mode']) {
+ $volume .= ':'.$parsed['mode']->value();
+ }
+ } elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') {
+ $volume = $source->value().':'.$target->value();
+ if (isset($parsed['mode']) && $parsed['mode']) {
+ $volume .= ':'.$parsed['mode']->value();
+ }
+ } else {
+ if ((int) $resource->compose_parsing_version >= 4) {
+ $mainDirectory = str(base_configuration_dir().'/services/'.$uuid);
+ } else {
+ $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
+ }
+ $source = replaceLocalSource($source, $mainDirectory);
+ LocalFileVolume::updateOrCreate(
+ [
+ 'mount_path' => $target,
+ 'resource_id' => $originalResource->id,
+ 'resource_type' => get_class($originalResource),
+ ],
+ [
+ 'fs_path' => $source,
+ 'mount_path' => $target,
+ 'content' => $content,
+ 'is_directory' => $isDirectory,
+ 'resource_id' => $originalResource->id,
+ 'resource_type' => get_class($originalResource),
+ ]
+ );
+ if (isDev()) {
+ if ((int) $resource->compose_parsing_version >= 4) {
+ $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/services/'.$uuid);
+ } else {
+ $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid);
+ }
+ }
+ $volume = "$source:$target";
+ if (isset($parsed['mode']) && $parsed['mode']) {
+ $volume .= ':'.$parsed['mode']->value();
+ }
+ }
+ } elseif ($type->value() === 'volume') {
+ if ($topLevel->get('volumes')->has($source->value())) {
+ $temp = $topLevel->get('volumes')->get($source->value());
+ if (data_get($temp, 'driver_opts.type') === 'cifs') {
+ continue;
+ }
+ if (data_get($temp, 'driver_opts.type') === 'nfs') {
+ continue;
+ }
+ }
+ $slugWithoutUuid = Str::slug($source, '-');
+ $name = "{$uuid}_{$slugWithoutUuid}";
+
+ if (is_string($volume)) {
+ $parsed = parseDockerVolumeString($volume);
+ $source = $parsed['source'];
+ $target = $parsed['target'];
+ $source = $name;
+ $volume = "$source:$target";
+ if (isset($parsed['mode']) && $parsed['mode']) {
+ $volume .= ':'.$parsed['mode']->value();
+ }
+ } elseif (is_array($volume)) {
+ data_set($volume, 'source', $name);
+ }
+ $topLevel->get('volumes')->put($name, [
+ 'name' => $name,
+ ]);
+ LocalPersistentVolume::updateOrCreate(
+ [
+ 'name' => $name,
+ 'resource_id' => $originalResource->id,
+ 'resource_type' => get_class($originalResource),
+ ],
+ [
+ 'name' => $name,
+ 'mount_path' => $target,
+ 'resource_id' => $originalResource->id,
+ 'resource_type' => get_class($originalResource),
+ ]
+ );
+ }
+ dispatch(new ServerFilesFromServerJob($originalResource));
+ $volumesParsed->put($index, $volume);
+ }
+ }
+
+ if (! $use_network_mode) {
+ if ($topLevel->get('networks')?->count() > 0) {
+ foreach ($topLevel->get('networks') as $networkName => $network) {
+ if ($networkName === 'default') {
+ continue;
+ }
+ // ignore aliases
+ if ($network['aliases'] ?? false) {
+ continue;
+ }
+ $networkExists = $networks->contains(function ($value, $key) use ($networkName) {
+ return $value == $networkName || $key == $networkName;
+ });
+ if (! $networkExists) {
+ $networks->put($networkName, null);
+ }
+ }
+ }
+ $baseNetworkExists = $networks->contains(function ($value, $_) use ($baseNetwork) {
+ return $value == $baseNetwork;
+ });
+ if (! $baseNetworkExists) {
+ foreach ($baseNetwork as $network) {
+ $topLevel->get('networks')->put($network, [
+ 'name' => $network,
+ 'external' => true,
+ ]);
+ }
+ }
+ }
+
+ // Collect/create/update ports
+ $collectedPorts = collect([]);
+ if ($ports->count() > 0) {
+ foreach ($ports as $sport) {
+ if (is_string($sport) || is_numeric($sport)) {
+ $collectedPorts->push($sport);
+ }
+ if (is_array($sport)) {
+ $target = data_get($sport, 'target');
+ $published = data_get($sport, 'published');
+ $protocol = data_get($sport, 'protocol');
+ $collectedPorts->push("$target:$published/$protocol");
+ }
+ }
+ }
+ $originalResource->ports = $collectedPorts->implode(',');
+ $originalResource->save();
+
+ $networks_temp = collect();
+
+ if (! $use_network_mode) {
+ foreach ($networks as $key => $network) {
+ if (gettype($network) === 'string') {
+ // networks:
+ // - appwrite
+ $networks_temp->put($network, null);
+ } elseif (gettype($network) === 'array') {
+ // networks:
+ // default:
+ // ipv4_address: 192.168.203.254
+ $networks_temp->put($key, $network);
+ }
+ }
+ foreach ($baseNetwork as $key => $network) {
+ $networks_temp->put($network, null);
+ }
+ }
+
+ $normalEnvironments = $environment->diffKeys($allMagicEnvironments);
+ $normalEnvironments = $normalEnvironments->filter(function ($value, $key) {
+ return ! str($value)->startsWith('SERVICE_');
+ });
+ foreach ($normalEnvironments as $key => $value) {
+ $key = str($key);
+ $value = str($value);
+ $originalValue = $value;
+ $parsedValue = replaceVariables($value);
+ if ($parsedValue->startsWith('SERVICE_')) {
+ $resource->environment_variables()->firstOrCreate([
+ 'key' => $key,
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $value,
+ 'is_preview' => false,
+ ]);
+
+ continue;
+ }
+ if (! $value->startsWith('$')) {
+ continue;
+ }
+ if ($key->value() === $parsedValue->value()) {
+ $value = null;
+ $resource->environment_variables()->firstOrCreate([
+ 'key' => $key,
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $value,
+ 'is_preview' => false,
+ ]);
+ } else {
+ if ($value->startsWith('$')) {
+ $isRequired = false;
+ if ($value->contains(':-')) {
+ $value = replaceVariables($value);
+ $key = $value->before(':');
+ $value = $value->after(':-');
+ } elseif ($value->contains('-')) {
+ $value = replaceVariables($value);
+
+ $key = $value->before('-');
+ $value = $value->after('-');
+ } elseif ($value->contains(':?')) {
+ $value = replaceVariables($value);
+
+ $key = $value->before(':');
+ $value = $value->after(':?');
+ $isRequired = true;
+ } elseif ($value->contains('?')) {
+ $value = replaceVariables($value);
+
+ $key = $value->before('?');
+ $value = $value->after('?');
+ $isRequired = true;
+ }
+ if ($originalValue->value() === $value->value()) {
+ // This means the variable does not have a default value, so it needs to be created in Coolify
+ $parsedKeyValue = replaceVariables($value);
+ $resource->environment_variables()->firstOrCreate([
+ 'key' => $parsedKeyValue,
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'is_preview' => false,
+ 'is_required' => $isRequired,
+ ]);
+ // Add the variable to the environment so it will be shown in the deployable compose file
+ $environment[$parsedKeyValue->value()] = $value;
+
+ continue;
+ }
+ $resource->environment_variables()->firstOrCreate([
+ 'key' => $key,
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $value,
+ 'is_preview' => false,
+ 'is_required' => $isRequired,
+ ]);
+ }
+ }
+ }
+
+ // Add COOLIFY_RESOURCE_UUID to environment
+ if ($resource->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
+ $coolifyEnvironments->put('COOLIFY_RESOURCE_UUID', "{$resource->uuid}");
+ }
+
+ // Add COOLIFY_CONTAINER_NAME to environment
+ if ($resource->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
+ $coolifyEnvironments->put('COOLIFY_CONTAINER_NAME', "{$containerName}");
+ }
+
+ if ($savedService->serviceType()) {
+ $fqdns = generateServiceSpecificFqdns($savedService);
+ } else {
+ $fqdns = collect(data_get($savedService, 'fqdns'))->filter();
+ }
+
+ $defaultLabels = defaultLabels(
+ id: $resource->id,
+ name: $containerName,
+ projectName: $resource->project()->name,
+ resourceName: $resource->name,
+ type: 'service',
+ subType: $isDatabase ? 'database' : 'application',
+ subId: $savedService->id,
+ subName: $savedService->human_name ?? $savedService->name,
+ environment: $resource->environment->name,
+ );
+
+ // Add COOLIFY_FQDN & COOLIFY_URL to environment
+ if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
+ $fqdnsWithoutPort = $fqdns->map(function ($fqdn) {
+ return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':');
+ });
+ $coolifyEnvironments->put('COOLIFY_FQDN', $fqdnsWithoutPort->implode(','));
+ $urls = $fqdns->map(function ($fqdn): Stringable {
+ return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://'));
+ });
+ $coolifyEnvironments->put('COOLIFY_URL', $urls->implode(','));
+ }
+ add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
+ if ($environment->count() > 0) {
+ $environment = $environment->filter(function ($value, $key) {
+ return ! str($key)->startsWith('SERVICE_FQDN_');
+ })->map(function ($value, $key) use ($resource) {
+ // if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used
+ if (str($value)->isEmpty()) {
+ if ($resource->environment_variables()->where('key', $key)->exists()) {
+ $value = $resource->environment_variables()->where('key', $key)->first()->value;
+ } else {
+ $value = null;
+ }
+ }
+
+ return $value;
+ });
+ }
+ $serviceLabels = $labels->merge($defaultLabels);
+ if ($serviceLabels->count() > 0) {
+ $isContainerLabelEscapeEnabled = data_get($resource, 'is_container_label_escape_enabled');
+ if ($isContainerLabelEscapeEnabled) {
+ $serviceLabels = $serviceLabels->map(function ($value, $key) {
+ return escapeDollarSign($value);
+ });
+ }
+ }
+ if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
+ $shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels;
+ $uuid = $resource->uuid;
+ $network = data_get($resource, 'destination.network');
+ if ($shouldGenerateLabelsExactly) {
+ switch ($server->proxyType()) {
+ case ProxyTypes::TRAEFIK->value:
+ $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
+ uuid: $uuid,
+ domains: $fqdns,
+ is_force_https_enabled: true,
+ serviceLabels: $serviceLabels,
+ is_gzip_enabled: $originalResource->isGzipEnabled(),
+ is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
+ service_name: $serviceName,
+ image: $image
+ ));
+ break;
+ case ProxyTypes::CADDY->value:
+ $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
+ network: $network,
+ uuid: $uuid,
+ domains: $fqdns,
+ is_force_https_enabled: true,
+ serviceLabels: $serviceLabels,
+ is_gzip_enabled: $originalResource->isGzipEnabled(),
+ is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
+ service_name: $serviceName,
+ image: $image,
+ predefinedPort: $predefinedPort
+ ));
+ break;
+ }
+ } else {
+ $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
+ uuid: $uuid,
+ domains: $fqdns,
+ is_force_https_enabled: true,
+ serviceLabels: $serviceLabels,
+ is_gzip_enabled: $originalResource->isGzipEnabled(),
+ is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
+ service_name: $serviceName,
+ image: $image
+ ));
+ $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
+ network: $network,
+ uuid: $uuid,
+ domains: $fqdns,
+ is_force_https_enabled: true,
+ serviceLabels: $serviceLabels,
+ is_gzip_enabled: $originalResource->isGzipEnabled(),
+ is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
+ service_name: $serviceName,
+ image: $image,
+ predefinedPort: $predefinedPort
+ ));
+ }
+ }
+ if (data_get($service, 'restart') === 'no' || data_get($service, 'exclude_from_hc')) {
+ $savedService->update(['exclude_from_status' => true]);
+ }
+ data_forget($service, 'volumes.*.content');
+ data_forget($service, 'volumes.*.isDirectory');
+ data_forget($service, 'volumes.*.is_directory');
+ data_forget($service, 'exclude_from_hc');
+
+ $volumesParsed = $volumesParsed->map(function ($volume) {
+ data_forget($volume, 'content');
+ data_forget($volume, 'is_directory');
+ data_forget($volume, 'isDirectory');
+
+ return $volume;
+ });
+
+ $payload = collect($service)->merge([
+ 'container_name' => $containerName,
+ 'restart' => $restart->value(),
+ 'labels' => $serviceLabels,
+ ]);
+ if (! $use_network_mode) {
+ $payload['networks'] = $networks_temp;
+ }
+ if ($ports->count() > 0) {
+ $payload['ports'] = $ports;
+ }
+ if ($volumesParsed->count() > 0) {
+ $payload['volumes'] = $volumesParsed;
+ }
+ if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) {
+ $payload['environment'] = $environment->merge($coolifyEnvironments);
+ }
+ if ($logging) {
+ $payload['logging'] = $logging;
+ }
+ if ($depends_on->count() > 0) {
+ $payload['depends_on'] = $depends_on;
+ }
+
+ $parsedServices->put($serviceName, $payload);
+ }
+ $topLevel->put('services', $parsedServices);
+
+ $customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets'];
+
+ $topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) {
+ return array_search($key, $customOrder);
+ });
+
+ $resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2);
+ data_forget($resource, 'environment_variables');
+ data_forget($resource, 'environment_variables_preview');
+ $resource->save();
+
+ return $topLevel;
+}
diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php
index cabdabaa7..5bc1d005e 100644
--- a/bootstrap/helpers/proxy.php
+++ b/bootstrap/helpers/proxy.php
@@ -1,6 +1,6 @@
map(function ($network) use ($array_of_networks) {
+ $filtered_networks = collect([]);
+ $networks->map(function ($network) use ($array_of_networks, $filtered_networks) {
+ if ($network === 'host') {
+ return; // network-scoped alias is supported only for containers in user defined networks
+ }
+
$array_of_networks[$network] = [
'external' => true,
];
+ $filtered_networks->push($network);
});
if ($proxy_type === ProxyTypes::TRAEFIK->value) {
$labels = [
@@ -155,7 +161,7 @@ function generate_default_proxy_configuration(Server $server)
'extra_hosts' => [
'host.docker.internal:host-gateway',
],
- 'networks' => $networks->toArray(),
+ 'networks' => $filtered_networks->toArray(),
'ports' => [
'80:80',
'443:443',
@@ -237,7 +243,7 @@ function generate_default_proxy_configuration(Server $server)
'CADDY_DOCKER_POLLING_INTERVAL=5s',
'CADDY_DOCKER_CADDYFILE_PATH=/dynamic/Caddyfile',
],
- 'networks' => $networks->toArray(),
+ 'networks' => $filtered_networks->toArray(),
'ports' => [
'80:80',
'443:443',
@@ -261,7 +267,7 @@ function generate_default_proxy_configuration(Server $server)
}
$config = Yaml::dump($config, 12, 2);
- SaveConfiguration::run($server, $config);
+ SaveProxyConfiguration::run($server, $config);
return $config;
}
diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php
index 6c1e2beab..56386a55f 100644
--- a/bootstrap/helpers/remoteProcess.php
+++ b/bootstrap/helpers/remoteProcess.php
@@ -60,15 +60,86 @@ function remote_process(
function instant_scp(string $source, string $dest, Server $server, $throwError = true)
{
- $scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest);
- $process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
- $output = trim($process->output());
- $exitCode = $process->exitCode();
- if ($exitCode !== 0) {
- return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null;
- }
+ return \App\Helpers\SshRetryHandler::retry(
+ function () use ($source, $dest, $server) {
+ $scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest);
+ $process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
- return $output === 'null' ? null : $output;
+ $output = trim($process->output());
+ $exitCode = $process->exitCode();
+
+ if ($exitCode !== 0) {
+ excludeCertainErrors($process->errorOutput(), $exitCode);
+ }
+
+ return $output === 'null' ? null : $output;
+ },
+ [
+ 'server' => $server->ip,
+ 'source' => $source,
+ 'dest' => $dest,
+ 'function' => 'instant_scp',
+ ],
+ $throwError
+ );
+}
+
+function transfer_file_to_container(string $content, string $container_path, string $deployment_uuid, Server $server, bool $throwError = true): ?string
+{
+ $temp_file = tempnam(sys_get_temp_dir(), 'coolify_env_');
+
+ try {
+ // Write content to temporary file
+ file_put_contents($temp_file, $content);
+
+ // Generate unique filename for server transfer
+ $server_temp_file = '/tmp/coolify_env_'.uniqid().'_'.$deployment_uuid;
+
+ // Transfer file to server
+ instant_scp($temp_file, $server_temp_file, $server, $throwError);
+
+ // Ensure parent directory exists in container, then copy file
+ $parent_dir = dirname($container_path);
+ $commands = [];
+ if ($parent_dir !== '.' && $parent_dir !== '/') {
+ $commands[] = executeInDocker($deployment_uuid, "mkdir -p \"$parent_dir\"");
+ }
+ $commands[] = "docker cp $server_temp_file $deployment_uuid:$container_path";
+ $commands[] = "rm -f $server_temp_file"; // Cleanup server temp file
+
+ return instant_remote_process_with_timeout($commands, $server, $throwError);
+
+ } finally {
+ // Always cleanup local temp file
+ if (file_exists($temp_file)) {
+ unlink($temp_file);
+ }
+ }
+}
+
+function transfer_file_to_server(string $content, string $server_path, Server $server, bool $throwError = true): ?string
+{
+ $temp_file = tempnam(sys_get_temp_dir(), 'coolify_env_');
+
+ try {
+ // Write content to temporary file
+ file_put_contents($temp_file, $content);
+
+ // Ensure parent directory exists on server
+ $parent_dir = dirname($server_path);
+ if ($parent_dir !== '.' && $parent_dir !== '/') {
+ instant_remote_process_with_timeout(["mkdir -p \"$parent_dir\""], $server, $throwError);
+ }
+
+ // Transfer file directly to server destination
+ return instant_scp($temp_file, $server_path, $server, $throwError);
+
+ } finally {
+ // Always cleanup local temp file
+ if (file_exists($temp_file)) {
+ unlink($temp_file);
+ }
+ }
}
function instant_remote_process_with_timeout(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string
@@ -79,54 +150,65 @@ function instant_remote_process_with_timeout(Collection|array $command, Server $
}
$command_string = implode("\n", $command);
- // $start_time = microtime(true);
- $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
- $process = Process::timeout(30)->run($sshCommand);
- // $end_time = microtime(true);
+ return \App\Helpers\SshRetryHandler::retry(
+ function () use ($server, $command_string) {
+ $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
+ $process = Process::timeout(30)->run($sshCommand);
- // $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds
- // ray('SSH command execution time:', $execution_time.' ms')->orange();
+ $output = trim($process->output());
+ $exitCode = $process->exitCode();
- $output = trim($process->output());
- $exitCode = $process->exitCode();
+ if ($exitCode !== 0) {
+ excludeCertainErrors($process->errorOutput(), $exitCode);
+ }
- if ($exitCode !== 0) {
- return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null;
- }
+ // Sanitize output to ensure valid UTF-8 encoding
+ $output = $output === 'null' ? null : sanitize_utf8_text($output);
- // Sanitize output to ensure valid UTF-8 encoding
- $output = $output === 'null' ? null : sanitize_utf8_text($output);
-
- return $output;
+ return $output;
+ },
+ [
+ 'server' => $server->ip,
+ 'command_preview' => substr($command_string, 0, 100),
+ 'function' => 'instant_remote_process_with_timeout',
+ ],
+ $throwError
+ );
}
function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string
{
$command = $command instanceof Collection ? $command->toArray() : $command;
+
if ($server->isNonRoot() && ! $no_sudo) {
$command = parseCommandsByLineForSudo(collect($command), $server);
}
$command_string = implode("\n", $command);
- // $start_time = microtime(true);
- $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
- $process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand);
- // $end_time = microtime(true);
+ return \App\Helpers\SshRetryHandler::retry(
+ function () use ($server, $command_string) {
+ $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
+ $process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand);
- // $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds
- // ray('SSH command execution time:', $execution_time.' ms')->orange();
+ $output = trim($process->output());
+ $exitCode = $process->exitCode();
- $output = trim($process->output());
- $exitCode = $process->exitCode();
+ if ($exitCode !== 0) {
+ excludeCertainErrors($process->errorOutput(), $exitCode);
+ }
- if ($exitCode !== 0) {
- return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null;
- }
+ // Sanitize output to ensure valid UTF-8 encoding
+ $output = $output === 'null' ? null : sanitize_utf8_text($output);
- // Sanitize output to ensure valid UTF-8 encoding
- $output = $output === 'null' ? null : sanitize_utf8_text($output);
-
- return $output;
+ return $output;
+ },
+ [
+ 'server' => $server->ip,
+ 'command_preview' => substr($command_string, 0, 100),
+ 'function' => 'instant_remote_process',
+ ],
+ $throwError
+ );
}
function excludeCertainErrors(string $errorOutput, ?int $exitCode = null)
@@ -136,11 +218,18 @@ function excludeCertainErrors(string $errorOutput, ?int $exitCode = null)
'Could not resolve hostname',
]);
$ignored = $ignoredErrors->contains(fn ($error) => Str::contains($errorOutput, $error));
+
+ // Ensure we always have a meaningful error message
+ $errorMessage = trim($errorOutput);
+ if (empty($errorMessage)) {
+ $errorMessage = "SSH command failed with exit code: $exitCode";
+ }
+
if ($ignored) {
// TODO: Create new exception and disable in sentry
- throw new \RuntimeException($errorOutput, $exitCode);
+ throw new \RuntimeException($errorMessage, $exitCode);
}
- throw new \RuntimeException($errorOutput, $exitCode);
+ throw new \RuntimeException($errorMessage, $exitCode);
}
function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null): Collection
diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php
index 1e1d2a073..a124272a2 100644
--- a/bootstrap/helpers/services.php
+++ b/bootstrap/helpers/services.php
@@ -1,7 +1,6 @@
image = $updatedImage;
$resource->save();
}
+
+ $serviceName = str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
+ $resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_FQDN_{$serviceName}%")->delete();
+ $resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_URL_{$serviceName}%")->delete();
+
if ($resource->fqdn) {
$resourceFqdns = str($resource->fqdn)->explode(',');
- if ($resourceFqdns->count() === 1) {
- $resourceFqdns = $resourceFqdns->first();
- $variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_');
- $fqdn = Url::fromString($resourceFqdns);
- $port = $fqdn->getPort();
- $path = $fqdn->getPath();
- $fqdn = $fqdn->getScheme().'://'.$fqdn->getHost();
- $fqdnValue = ($path === '/') ? $fqdn : $fqdn.$path;
- EnvironmentVariable::updateOrCreate([
- 'resourceable_type' => Service::class,
- 'resourceable_id' => $resource->service_id,
- 'key' => $variableName,
- ], [
- 'value' => $fqdnValue,
- 'is_build_time' => false,
- 'is_preview' => false,
- ]);
- if ($port) {
- $variableName = $variableName."_$port";
- EnvironmentVariable::updateOrCreate([
- 'resourceable_type' => Service::class,
- 'resourceable_id' => $resource->service_id,
- 'key' => $variableName,
- ], [
- 'value' => $fqdnValue,
- 'is_build_time' => false,
- 'is_preview' => false,
- ]);
- }
- $variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_');
- $url = Url::fromString($fqdn);
- $port = $url->getPort();
- $path = $url->getPath();
- $url = $url->getHost();
- $urlValue = str($fqdn)->after('://');
- if ($path !== '/') {
- $urlValue = $urlValue.$path;
- }
- EnvironmentVariable::updateOrCreate([
+ $resourceFqdns = $resourceFqdns->first();
+ $variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
+ $url = Url::fromString($resourceFqdns);
+ $port = $url->getPort();
+ $path = $url->getPath();
+ $urlValue = $url->getScheme().'://'.$url->getHost();
+ $urlValue = ($path === '/') ? $urlValue : $urlValue.$path;
+ $resource->service->environment_variables()->updateOrCreate([
+ 'resourceable_type' => Service::class,
+ 'resourceable_id' => $resource->service_id,
+ 'key' => $variableName,
+ ], [
+ 'value' => $urlValue,
+ 'is_preview' => false,
+ ]);
+ if ($port) {
+ $variableName = $variableName."_$port";
+ $resource->service->environment_variables()->updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $urlValue,
- 'is_build_time' => false,
'is_preview' => false,
]);
- if ($port) {
- $variableName = $variableName."_$port";
- EnvironmentVariable::updateOrCreate([
- 'resourceable_type' => Service::class,
- 'resourceable_id' => $resource->service_id,
- 'key' => $variableName,
- ], [
- 'value' => $urlValue,
- 'is_build_time' => false,
- 'is_preview' => false,
- ]);
- }
- } elseif ($resourceFqdns->count() > 1) {
- foreach ($resourceFqdns as $fqdn) {
- $host = Url::fromString($fqdn);
- $port = $host->getPort();
- $url = $host->getHost();
- $path = $host->getPath();
- $host = $host->getScheme().'://'.$host->getHost();
- if ($port) {
- $port_envs = EnvironmentVariable::where('resourceable_type', Service::class)
- ->where('resourceable_id', $resource->service_id)
- ->where('key', 'like', "SERVICE_FQDN_%_$port")
- ->get();
- foreach ($port_envs as $port_env) {
- $service_fqdn = str($port_env->key)->beforeLast('_')->after('SERVICE_FQDN_');
- $env = EnvironmentVariable::where('resourceable_type', Service::class)
- ->where('resourceable_id', $resource->service_id)
- ->where('key', 'SERVICE_FQDN_'.$service_fqdn)
- ->first();
- if ($env) {
- if ($path === '/') {
- $env->value = $host;
- } else {
- $env->value = $host.$path;
- }
- $env->save();
- }
- if ($path === '/') {
- $port_env->value = $host;
- } else {
- $port_env->value = $host.$path;
- }
- $port_env->save();
- }
- $port_envs_url = EnvironmentVariable::where('resourceable_type', Service::class)
- ->where('resourceable_id', $resource->service_id)
- ->where('key', 'like', "SERVICE_URL_%_$port")
- ->get();
- foreach ($port_envs_url as $port_env_url) {
- $service_url = str($port_env_url->key)->beforeLast('_')->after('SERVICE_URL_');
- $env = EnvironmentVariable::where('resourceable_type', Service::class)
- ->where('resourceable_id', $resource->service_id)
- ->where('key', 'SERVICE_URL_'.$service_url)
- ->first();
- if ($env) {
- if ($path === '/') {
- $env->value = $url;
- } else {
- $env->value = $url.$path;
- }
- $env->save();
- }
- if ($path === '/') {
- $port_env_url->value = $url;
- } else {
- $port_env_url->value = $url.$path;
- }
- $port_env_url->save();
- }
- } else {
- $variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_');
- $generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
- ->where('resourceable_id', $resource->service_id)
- ->where('key', $variableName)
- ->first();
- $fqdn = Url::fromString($fqdn);
- $fqdn = $fqdn->getScheme().'://'.$fqdn->getHost().$fqdn->getPath();
- if ($generatedEnv) {
- $generatedEnv->value = $fqdn;
- $generatedEnv->save();
- }
- $variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_');
- $generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
- ->where('resourceable_id', $resource->service_id)
- ->where('key', $variableName)
- ->first();
- $url = Url::fromString($fqdn);
- $url = $url->getHost().$url->getPath();
- if ($generatedEnv) {
- $url = str($fqdn)->after('://');
- $generatedEnv->value = $url;
- $generatedEnv->save();
- }
- }
- }
}
- } else {
- // If FQDN is removed, delete the corresponding environment variables
- $serviceName = str($resource->name)->upper()->replace('-', '_');
- EnvironmentVariable::where('resourceable_type', Service::class)
- ->where('resourceable_id', $resource->service_id)
- ->where('key', 'LIKE', "SERVICE_FQDN_{$serviceName}%")
- ->delete();
- EnvironmentVariable::where('resourceable_type', Service::class)
- ->where('resourceable_id', $resource->service_id)
- ->where('key', 'LIKE', "SERVICE_URL_{$serviceName}%")
- ->delete();
+ $variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
+ $fqdn = Url::fromString($resourceFqdns);
+ $port = $fqdn->getPort();
+ $path = $fqdn->getPath();
+ $fqdn = $fqdn->getHost();
+ $fqdnValue = str($fqdn)->after('://');
+ if ($path !== '/') {
+ $fqdnValue = $fqdnValue.$path;
+ }
+ $resource->service->environment_variables()->updateOrCreate([
+ 'resourceable_type' => Service::class,
+ 'resourceable_id' => $resource->service_id,
+ 'key' => $variableName,
+ ], [
+ 'value' => $fqdnValue,
+ 'is_preview' => false,
+ ]);
+ if ($port) {
+ $variableName = $variableName."_$port";
+ $resource->service->environment_variables()->updateOrCreate([
+ 'resourceable_type' => Service::class,
+ 'resourceable_id' => $resource->service_id,
+ 'key' => $variableName,
+ ], [
+ 'value' => $fqdnValue,
+ 'is_preview' => false,
+ ]);
+ }
}
} catch (\Throwable $e) {
return handleError($e);
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index 4e77b35c3..656c607bf 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -204,7 +204,6 @@ function get_latest_version_of_coolify(): string
return data_get($versions, 'coolify.v4.version');
} catch (\Throwable $e) {
- ray($e->getMessage());
return '0.0.0';
}
@@ -402,7 +401,7 @@ function data_get_str($data, $key, $default = null): Stringable
return str($str);
}
-function generateFqdn(Server $server, string $random, bool $forceHttps = false): string
+function generateUrl(Server $server, string $random, bool $forceHttps = false): string
{
$wildcard = data_get($server, 'settings.wildcard_domain');
if (is_null($wildcard) || $wildcard === '') {
@@ -418,6 +417,27 @@ function generateFqdn(Server $server, string $random, bool $forceHttps = false):
return "$scheme://{$random}.$host$path";
}
+function generateFqdn(Server $server, string $random, bool $forceHttps = false, int $parserVersion = 5): string
+{
+
+ $wildcard = data_get($server, 'settings.wildcard_domain');
+ if (is_null($wildcard) || $wildcard === '') {
+ $wildcard = sslip($server);
+ }
+ $url = Url::fromString($wildcard);
+ $host = $url->getHost();
+ $path = $url->getPath() === '/' ? '' : $url->getPath();
+ $scheme = $url->getScheme();
+ if ($forceHttps) {
+ $scheme = 'https';
+ }
+
+ if ($parserVersion >= 5 && version_compare(config('constants.coolify.version'), '4.0.0-beta.420.7', '>=')) {
+ return "{$random}.$host$path";
+ }
+
+ return "$scheme://{$random}.$host$path";
+}
function sslip(Server $server)
{
if (isDev() && $server->id === 0) {
@@ -451,12 +471,12 @@ function get_service_templates(bool $force = false): Collection
return collect($services);
} catch (\Throwable) {
- $services = File::get(base_path('templates/service-templates.json'));
+ $services = File::get(base_path('templates/'.config('constants.services.file_name')));
return collect(json_decode($services))->sortKeys();
}
} else {
- $services = File::get(base_path('templates/service-templates.json'));
+ $services = File::get(base_path('templates/'.config('constants.services.file_name')));
return collect(json_decode($services))->sortKeys();
}
@@ -945,7 +965,7 @@ function getRealtime()
}
}
-function validate_dns_entry(string $fqdn, Server $server)
+function validateDNSEntry(string $fqdn, Server $server)
{
// https://www.cloudflare.com/ips-v4/#
$cloudflare_ips = collect(['173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13', '172.64.0.0/13', '131.0.72.0/22']);
@@ -978,7 +998,7 @@ function validate_dns_entry(string $fqdn, Server $server)
} else {
foreach ($results as $result) {
if ($result->getType() == $type) {
- if (ip_match($result->getData(), $cloudflare_ips->toArray(), $match)) {
+ if (ipMatch($result->getData(), $cloudflare_ips->toArray(), $match)) {
$found_matching_ip = true;
break;
}
@@ -996,7 +1016,7 @@ function validate_dns_entry(string $fqdn, Server $server)
return $found_matching_ip;
}
-function ip_match($ip, $cidrs, &$match = null)
+function ipMatch($ip, $cidrs, &$match = null)
{
foreach ((array) $cidrs as $cidr) {
[$subnet, $mask] = explode('/', $cidr);
@@ -1009,223 +1029,63 @@ function ip_match($ip, $cidrs, &$match = null)
return false;
}
-function checkIfDomainIsAlreadyUsed(Collection|array $domains, ?string $teamId = null, ?string $uuid = null)
+
+function checkIPAgainstAllowlist($ip, $allowlist)
{
- if (is_null($teamId)) {
- return response()->json(['error' => 'Team ID is required.'], 400);
- }
- if (is_array($domains)) {
- $domains = collect($domains);
+ if (empty($allowlist)) {
+ return false;
}
- $domains = $domains->map(function ($domain) {
- if (str($domain)->endsWith('/')) {
- $domain = str($domain)->beforeLast('/');
+ foreach ((array) $allowlist as $allowed) {
+ $allowed = trim($allowed);
+
+ if (empty($allowed)) {
+ continue;
}
- return str($domain);
- });
- $applications = Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid']);
- $serviceApplications = ServiceApplication::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid']);
- if ($uuid) {
- $applications = $applications->filter(fn ($app) => $app->uuid !== $uuid);
- $serviceApplications = $serviceApplications->filter(fn ($app) => $app->uuid !== $uuid);
- }
- $domainFound = false;
- foreach ($applications as $app) {
- if (is_null($app->fqdn)) {
- continue;
- }
- $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
- foreach ($list_of_domains as $domain) {
- if (str($domain)->endsWith('/')) {
- $domain = str($domain)->beforeLast('/');
+ // Check if it's a CIDR notation
+ if (str_contains($allowed, '/')) {
+ [$subnet, $mask] = explode('/', $allowed);
+
+ // Special case: 0.0.0.0 with any subnet means allow all
+ if ($subnet === '0.0.0.0') {
+ return true;
}
- $naked_domain = str($domain)->value();
- if ($domains->contains($naked_domain)) {
- $domainFound = true;
- break;
+
+ $mask = (int) $mask;
+
+ // Validate mask
+ if ($mask < 0 || $mask > 32) {
+ continue;
}
- }
- }
- if ($domainFound) {
- return true;
- }
- foreach ($serviceApplications as $app) {
- if (str($app->fqdn)->isEmpty()) {
- continue;
- }
- $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
- foreach ($list_of_domains as $domain) {
- if (str($domain)->endsWith('/')) {
- $domain = str($domain)->beforeLast('/');
+
+ // Calculate network addresses
+ $ip_long = ip2long($ip);
+ $subnet_long = ip2long($subnet);
+
+ if ($ip_long === false || $subnet_long === false) {
+ continue;
}
- $naked_domain = str($domain)->value();
- if ($domains->contains($naked_domain)) {
- $domainFound = true;
- break;
+
+ $mask_long = ~((1 << (32 - $mask)) - 1);
+
+ if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) {
+ return true;
}
- }
- }
- if ($domainFound) {
- return true;
- }
- $settings = instanceSettings();
- if (data_get($settings, 'fqdn')) {
- $domain = data_get($settings, 'fqdn');
- if (str($domain)->endsWith('/')) {
- $domain = str($domain)->beforeLast('/');
- }
- $naked_domain = str($domain)->value();
- if ($domains->contains($naked_domain)) {
- return true;
- }
- }
-}
-function check_domain_usage(ServiceApplication|Application|null $resource = null, ?string $domain = null)
-{
- if ($resource) {
- if ($resource->getMorphClass() === \App\Models\Application::class && $resource->build_pack === 'dockercompose') {
- $domains = data_get(json_decode($resource->docker_compose_domains, true), '*.domain');
- $domains = collect($domains);
} else {
- $domains = collect($resource->fqdns);
- }
- } elseif ($domain) {
- $domains = collect($domain);
- } else {
- throw new \RuntimeException('No resource or FQDN provided.');
- }
- $domains = $domains->map(function ($domain) {
- if (str($domain)->endsWith('/')) {
- $domain = str($domain)->beforeLast('/');
- }
-
- return str($domain);
- });
- $apps = Application::all();
- foreach ($apps as $app) {
- $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
- foreach ($list_of_domains as $domain) {
- if (str($domain)->endsWith('/')) {
- $domain = str($domain)->beforeLast('/');
+ // Special case: 0.0.0.0 means allow all
+ if ($allowed === '0.0.0.0') {
+ return true;
}
- $naked_domain = str($domain)->value();
- if ($domains->contains($naked_domain)) {
- if (data_get($resource, 'uuid')) {
- if ($resource->uuid !== $app->uuid) {
- throw new \RuntimeException("Domain $naked_domain is already in use by another resource:
Link: {$app->name}");
- }
- } elseif ($domain) {
- throw new \RuntimeException("Domain $naked_domain is already in use by another resource:
Link: {$app->name}");
- }
+
+ // Direct IP comparison
+ if ($ip === $allowed) {
+ return true;
}
}
}
- $apps = ServiceApplication::all();
- foreach ($apps as $app) {
- $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
- foreach ($list_of_domains as $domain) {
- if (str($domain)->endsWith('/')) {
- $domain = str($domain)->beforeLast('/');
- }
- $naked_domain = str($domain)->value();
- if ($domains->contains($naked_domain)) {
- if (data_get($resource, 'uuid')) {
- if ($resource->uuid !== $app->uuid) {
- throw new \RuntimeException("Domain $naked_domain is already in use by another resource:
Link: {$app->service->name}");
- }
- } elseif ($domain) {
- throw new \RuntimeException("Domain $naked_domain is already in use by another resource:
Link: {$app->service->name}");
- }
- }
- }
- }
- if ($resource) {
- $settings = instanceSettings();
- if (data_get($settings, 'fqdn')) {
- $domain = data_get($settings, 'fqdn');
- if (str($domain)->endsWith('/')) {
- $domain = str($domain)->beforeLast('/');
- }
- $naked_domain = str($domain)->value();
- if ($domains->contains($naked_domain)) {
- throw new \RuntimeException("Domain $naked_domain is already in use by this Coolify instance.");
- }
- }
- }
-}
-function parseCommandsByLineForSudo(Collection $commands, Server $server): array
-{
- $commands = $commands->map(function ($line) {
- if (
- ! str(trim($line))->startsWith([
- 'cd',
- 'command',
- 'echo',
- 'true',
- 'if',
- 'fi',
- ])
- ) {
- return "sudo $line";
- }
-
- if (str(trim($line))->startsWith('if')) {
- return str_replace('if', 'if sudo', $line);
- }
-
- return $line;
- });
-
- $commands = $commands->map(function ($line) use ($server) {
- if (Str::startsWith($line, 'sudo mkdir -p')) {
- return "$line && sudo chown -R $server->user:$server->user ".Str::after($line, 'sudo mkdir -p').' && sudo chmod -R o-rwx '.Str::after($line, 'sudo mkdir -p');
- }
-
- return $line;
- });
-
- $commands = $commands->map(function ($line) {
- $line = str($line);
- if (str($line)->contains('$(')) {
- $line = $line->replace('$(', '$(sudo ');
- }
- if (str($line)->contains('||')) {
- $line = $line->replace('||', '|| sudo');
- }
- if (str($line)->contains('&&')) {
- $line = $line->replace('&&', '&& sudo');
- }
- if (str($line)->contains(' | ')) {
- $line = $line->replace(' | ', ' | sudo ');
- }
-
- return $line->value();
- });
-
- return $commands->toArray();
-}
-function parseLineForSudo(string $command, Server $server): string
-{
- if (! str($command)->startSwith('cd') && ! str($command)->startSwith('command')) {
- $command = "sudo $command";
- }
- if (Str::startsWith($command, 'sudo mkdir -p')) {
- $command = "$command && sudo chown -R $server->user:$server->user ".Str::after($command, 'sudo mkdir -p').' && sudo chmod -R o-rwx '.Str::after($command, 'sudo mkdir -p');
- }
- if (str($command)->contains('$(') || str($command)->contains('`')) {
- $command = str($command)->replace('$(', '$(sudo ')->replace('`', '`sudo ')->value();
- }
- if (str($command)->contains('||')) {
- $command = str($command)->replace('||', '|| sudo ')->value();
- }
- if (str($command)->contains('&&')) {
- $command = str($command)->replace('&&', '&& sudo ')->value();
- }
-
- return $command;
+ return false;
}
function get_public_ips()
@@ -1269,30 +1129,77 @@ function get_public_ips()
function isAnyDeploymentInprogress()
{
$runningJobs = ApplicationDeploymentQueue::where('horizon_job_worker', gethostname())->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)->get();
- $basicDetails = $runningJobs->map(function ($job) {
- return [
- 'id' => $job->id,
- 'created_at' => $job->created_at,
- 'application_id' => $job->application_id,
- 'server_id' => $job->server_id,
- 'horizon_job_id' => $job->horizon_job_id,
- 'status' => $job->status,
- ];
- });
- echo 'Running jobs: '.json_encode($basicDetails)."\n";
+
+ if ($runningJobs->isEmpty()) {
+ echo "No deployments in progress.\n";
+ exit(0);
+ }
+
$horizonJobIds = [];
+ $deploymentDetails = [];
+
foreach ($runningJobs as $runningJob) {
$horizonJobStatus = getJobStatus($runningJob->horizon_job_id);
if ($horizonJobStatus === 'unknown' || $horizonJobStatus === 'reserved') {
$horizonJobIds[] = $runningJob->horizon_job_id;
+
+ // Get application and team information
+ $application = Application::find($runningJob->application_id);
+ $teamMembers = [];
+ $deploymentUrl = '';
+
+ if ($application) {
+ // Get team members through the application's project
+ $team = $application->team();
+ if ($team) {
+ $teamMembers = $team->members()->pluck('email')->toArray();
+ }
+
+ // Construct the full deployment URL
+ if ($runningJob->deployment_url) {
+ $baseUrl = base_url();
+ $deploymentUrl = $baseUrl.$runningJob->deployment_url;
+ }
+ }
+
+ $deploymentDetails[] = [
+ 'id' => $runningJob->id,
+ 'application_name' => $runningJob->application_name ?? 'Unknown',
+ 'server_name' => $runningJob->server_name ?? 'Unknown',
+ 'deployment_url' => $deploymentUrl,
+ 'team_members' => $teamMembers,
+ 'created_at' => $runningJob->created_at->format('Y-m-d H:i:s'),
+ 'horizon_job_id' => $runningJob->horizon_job_id,
+ ];
}
}
+
if (count($horizonJobIds) === 0) {
- echo "No deployments in progress.\n";
+ echo "No active deployments in progress (all jobs completed or failed).\n";
exit(0);
}
- $horizonJobIds = collect($horizonJobIds)->unique()->toArray();
- echo 'There are '.count($horizonJobIds)." deployments in progress.\n";
+
+ // Display enhanced deployment information
+ echo "\n=== Running Deployments ===\n";
+ echo 'Total active deployments: '.count($horizonJobIds)."\n\n";
+
+ foreach ($deploymentDetails as $index => $deployment) {
+ echo 'Deployment #'.($index + 1).":\n";
+ echo ' Application: '.$deployment['application_name']."\n";
+ echo ' Server: '.$deployment['server_name']."\n";
+ echo ' Started: '.$deployment['created_at']."\n";
+ if ($deployment['deployment_url']) {
+ echo ' URL: '.$deployment['deployment_url']."\n";
+ }
+ if (! empty($deployment['team_members'])) {
+ echo ' Team members: '.implode(', ', $deployment['team_members'])."\n";
+ } else {
+ echo " Team members: No team members found\n";
+ }
+ echo ' Horizon Job ID: '.$deployment['horizon_job_id']."\n";
+ echo "\n";
+ }
+
exit(1);
}
@@ -1310,143 +1217,6 @@ function customApiValidator(Collection|array $item, array $rules)
'required' => 'This field is required.',
]);
}
-
-function parseServiceVolumes($serviceVolumes, $resource, $topLevelVolumes, $pull_request_id = 0)
-{
- $serviceVolumes = $serviceVolumes->map(function ($volume) use ($resource, $topLevelVolumes, $pull_request_id) {
- $type = null;
- $source = null;
- $target = null;
- $content = null;
- $isDirectory = false;
- if (is_string($volume)) {
- $source = str($volume)->before(':');
- $target = str($volume)->after(':')->beforeLast(':');
- $foundConfig = $resource->fileStorages()->whereMountPath($target)->first();
- if ($source->startsWith('./') || $source->startsWith('/') || $source->startsWith('~')) {
- $type = str('bind');
- if ($foundConfig) {
- $contentNotNull = data_get($foundConfig, 'content');
- if ($contentNotNull) {
- $content = $contentNotNull;
- }
- $isDirectory = data_get($foundConfig, 'is_directory');
- } else {
- // By default, we cannot determine if the bind is a directory or not, so we set it to directory
- $isDirectory = true;
- }
- } else {
- $type = str('volume');
- }
- } elseif (is_array($volume)) {
- $type = data_get_str($volume, 'type');
- $source = data_get_str($volume, 'source');
- $target = data_get_str($volume, 'target');
- $content = data_get($volume, 'content');
- $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
- $foundConfig = $resource->fileStorages()->whereMountPath($target)->first();
- if ($foundConfig) {
- $contentNotNull = data_get($foundConfig, 'content');
- if ($contentNotNull) {
- $content = $contentNotNull;
- }
- $isDirectory = data_get($foundConfig, 'is_directory');
- } else {
- $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
- if ((is_null($isDirectory) || ! $isDirectory) && is_null($content)) {
- // if isDirectory is not set (or false) & content is also not set, we assume it is a directory
- $isDirectory = true;
- }
- }
- }
- if ($type?->value() === 'bind') {
- if ($source->value() === '/var/run/docker.sock') {
- return $volume;
- }
- if ($source->value() === '/tmp' || $source->value() === '/tmp/') {
- return $volume;
- }
- if (get_class($resource) === \App\Models\Application::class) {
- $dir = base_configuration_dir().'/applications/'.$resource->uuid;
- } else {
- $dir = base_configuration_dir().'/services/'.$resource->service->uuid;
- }
-
- if ($source->startsWith('.')) {
- $source = $source->replaceFirst('.', $dir);
- }
- if ($source->startsWith('~')) {
- $source = $source->replaceFirst('~', $dir);
- }
- if ($pull_request_id !== 0) {
- $source = $source."-pr-$pull_request_id";
- }
- if (! $resource?->settings?->is_preserve_repository_enabled || $foundConfig?->is_based_on_git) {
- LocalFileVolume::updateOrCreate(
- [
- 'mount_path' => $target,
- 'resource_id' => $resource->id,
- 'resource_type' => get_class($resource),
- ],
- [
- 'fs_path' => $source,
- 'mount_path' => $target,
- 'content' => $content,
- 'is_directory' => $isDirectory,
- 'resource_id' => $resource->id,
- 'resource_type' => get_class($resource),
- ]
- );
- }
- } elseif ($type->value() === 'volume') {
- if ($topLevelVolumes->has($source->value())) {
- $v = $topLevelVolumes->get($source->value());
- if (data_get($v, 'driver_opts.type') === 'cifs') {
- return $volume;
- }
- }
- $slugWithoutUuid = Str::slug($source, '-');
- if (get_class($resource) === \App\Models\Application::class) {
- $name = "{$resource->uuid}_{$slugWithoutUuid}";
- } else {
- $name = "{$resource->service->uuid}_{$slugWithoutUuid}";
- }
- if (is_string($volume)) {
- $source = str($volume)->before(':');
- $target = str($volume)->after(':')->beforeLast(':');
- $source = $name;
- $volume = "$source:$target";
- } elseif (is_array($volume)) {
- data_set($volume, 'source', $name);
- }
- $topLevelVolumes->put($name, [
- 'name' => $name,
- ]);
- LocalPersistentVolume::updateOrCreate(
- [
- 'mount_path' => $target,
- 'resource_id' => $resource->id,
- 'resource_type' => get_class($resource),
- ],
- [
- 'name' => $name,
- 'mount_path' => $target,
- 'resource_id' => $resource->id,
- 'resource_type' => get_class($resource),
- ]
- );
- }
- dispatch(new ServerFilesFromServerJob($resource));
-
- return $volume;
- });
-
- return [
- 'serviceVolumes' => $serviceVolumes,
- 'topLevelVolumes' => $topLevelVolumes,
- ];
-}
-
function parseDockerComposeFile(Service|Application $resource, bool $isNew = false, int $pull_request_id = 0, ?int $preview_id = null)
{
if ($resource->getMorphClass() === \App\Models\Service::class) {
@@ -1850,7 +1620,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
EnvironmentVariable::create([
'key' => $key,
'value' => $fqdn,
- 'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@@ -1930,7 +1699,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
EnvironmentVariable::create([
'key' => $key,
'value' => $fqdn,
- 'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@@ -1969,7 +1737,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
EnvironmentVariable::create([
'key' => $key,
'value' => $generatedValue,
- 'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@@ -2008,7 +1775,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
'resourceable_id' => $resource->id,
], [
'value' => $defaultValue,
- 'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@@ -2272,12 +2038,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$name = $name->replaceFirst('~', $dir);
}
if ($pull_request_id !== 0) {
- $name = $name."-pr-$pull_request_id";
+ $name = addPreviewDeploymentSuffix($name, $pull_request_id);
}
$volume = str("$name:$mount");
} else {
if ($pull_request_id !== 0) {
- $name = $name."-pr-$pull_request_id";
+ $name = addPreviewDeploymentSuffix($name, $pull_request_id);
$volume = str("$name:$mount");
if ($topLevelVolumes->has($name)) {
$v = $topLevelVolumes->get($name);
@@ -2316,7 +2082,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$name = $volume->before(':');
$mount = $volume->after(':');
if ($pull_request_id !== 0) {
- $name = $name."-pr-$pull_request_id";
+ $name = addPreviewDeploymentSuffix($name, $pull_request_id);
}
$volume = str("$name:$mount");
}
@@ -2335,7 +2101,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$source = str($source)->replaceFirst('~', $dir);
}
if ($pull_request_id !== 0) {
- $source = $source."-pr-$pull_request_id";
+ $source = addPreviewDeploymentSuffix($source, $pull_request_id);
}
if ($read_only) {
data_set($volume, 'source', $source.':'.$target.':ro');
@@ -2344,7 +2110,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
} else {
if ($pull_request_id !== 0) {
- $source = $source."-pr-$pull_request_id";
+ $source = addPreviewDeploymentSuffix($source, $pull_request_id);
}
if ($read_only) {
data_set($volume, 'source', $source.':'.$target.':ro');
@@ -2396,13 +2162,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$name = $name->replaceFirst('~', $dir);
}
if ($pull_request_id !== 0) {
- $name = $name."-pr-$pull_request_id";
+ $name = addPreviewDeploymentSuffix($name, $pull_request_id);
}
$volume = str("$name:$mount");
} else {
if ($pull_request_id !== 0) {
$uuid = $resource->uuid;
- $name = $uuid."-$name-pr-$pull_request_id";
+ $name = $uuid.'-'.addPreviewDeploymentSuffix($name, $pull_request_id);
$volume = str("$name:$mount");
if ($topLevelVolumes->has($name)) {
$v = $topLevelVolumes->get($name);
@@ -2444,7 +2210,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$name = $volume->before(':');
$mount = $volume->after(':');
if ($pull_request_id !== 0) {
- $name = $name."-pr-$pull_request_id";
+ $name = addPreviewDeploymentSuffix($name, $pull_request_id);
}
$volume = str("$name:$mount");
}
@@ -2472,7 +2238,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($pull_request_id === 0) {
$source = $uuid."-$source";
} else {
- $source = $uuid."-$source-pr-$pull_request_id";
+ $source = $uuid.'-'.addPreviewDeploymentSuffix($source, $pull_request_id);
}
if ($read_only) {
data_set($volume, 'source', $source.':'.$target.':ro');
@@ -2512,7 +2278,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($pull_request_id !== 0 && count($serviceDependencies) > 0) {
$serviceDependencies = $serviceDependencies->map(function ($dependency) use ($pull_request_id) {
- return $dependency."-pr-$pull_request_id";
+ return addPreviewDeploymentSuffix($dependency, $pull_request_id);
});
data_set($service, 'depends_on', $serviceDependencies->toArray());
}
@@ -2699,7 +2465,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
EnvironmentVariable::create([
'key' => $key,
'value' => $fqdn,
- 'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@@ -2711,7 +2476,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
EnvironmentVariable::create([
'key' => $key,
'value' => $generatedValue,
- 'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@@ -2745,20 +2509,17 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($foundEnv) {
$defaultValue = data_get($foundEnv, 'value');
}
- $isBuildTime = data_get($foundEnv, 'is_build_time', false);
if ($foundEnv) {
$foundEnv->update([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
- 'is_build_time' => $isBuildTime,
'value' => $defaultValue,
]);
} else {
EnvironmentVariable::create([
'key' => $key,
'value' => $defaultValue,
- 'is_build_time' => $isBuildTime,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@@ -2906,7 +2667,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
});
if ($pull_request_id !== 0) {
$services->each(function ($service, $serviceName) use ($pull_request_id, $services) {
- $services[$serviceName."-pr-$pull_request_id"] = $service;
+ $services[addPreviewDeploymentSuffix($serviceName, $pull_request_id)] = $service;
data_forget($services, $serviceName);
});
}
@@ -2927,1008 +2688,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
}
-function newParser(Application|Service $resource, int $pull_request_id = 0, ?int $preview_id = null): Collection
-{
- $isApplication = $resource instanceof Application;
- $isService = $resource instanceof Service;
-
- $uuid = data_get($resource, 'uuid');
- $compose = data_get($resource, 'docker_compose_raw');
- if (! $compose) {
- return collect([]);
- }
-
- if ($isApplication) {
- $pullRequestId = $pull_request_id;
- $isPullRequest = $pullRequestId == 0 ? false : true;
- $server = data_get($resource, 'destination.server');
- $fileStorages = $resource->fileStorages();
- } elseif ($isService) {
- $server = data_get($resource, 'server');
- $allServices = get_service_templates();
- } else {
- return collect([]);
- }
-
- try {
- $yaml = Yaml::parse($compose);
- } catch (\Exception) {
- return collect([]);
- }
- $services = data_get($yaml, 'services', collect([]));
- $topLevel = collect([
- 'volumes' => collect(data_get($yaml, 'volumes', [])),
- 'networks' => collect(data_get($yaml, 'networks', [])),
- 'configs' => collect(data_get($yaml, 'configs', [])),
- 'secrets' => collect(data_get($yaml, 'secrets', [])),
- ]);
- // If there are predefined volumes, make sure they are not null
- if ($topLevel->get('volumes')->count() > 0) {
- $temp = collect([]);
- foreach ($topLevel['volumes'] as $volumeName => $volume) {
- if (is_null($volume)) {
- continue;
- }
- $temp->put($volumeName, $volume);
- }
- $topLevel['volumes'] = $temp;
- }
- // Get the base docker network
- $baseNetwork = collect([$uuid]);
- if ($isApplication && $isPullRequest) {
- $baseNetwork = collect(["{$uuid}-{$pullRequestId}"]);
- }
-
- $parsedServices = collect([]);
-
- $allMagicEnvironments = collect([]);
- foreach ($services as $serviceName => $service) {
- $predefinedPort = null;
- $magicEnvironments = collect([]);
- $image = data_get_str($service, 'image');
- $environment = collect(data_get($service, 'environment', []));
- $buildArgs = collect(data_get($service, 'build.args', []));
- $environment = $environment->merge($buildArgs);
- $isDatabase = isDatabaseImage($image, $service);
-
- if ($isService) {
- $containerName = "$serviceName-{$resource->uuid}";
-
- if ($serviceName === 'registry') {
- $tempServiceName = 'docker-registry';
- } else {
- $tempServiceName = $serviceName;
- }
- if (str(data_get($service, 'image'))->contains('glitchtip')) {
- $tempServiceName = 'glitchtip';
- }
- if ($serviceName === 'supabase-kong') {
- $tempServiceName = 'supabase';
- }
- $serviceDefinition = data_get($allServices, $tempServiceName);
- $predefinedPort = data_get($serviceDefinition, 'port');
- if ($serviceName === 'plausible') {
- $predefinedPort = '8000';
- }
- if ($isDatabase) {
- $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
- if ($applicationFound) {
- $savedService = $applicationFound;
- } else {
- $savedService = ServiceDatabase::firstOrCreate([
- 'name' => $serviceName,
- 'service_id' => $resource->id,
- ]);
- }
- } else {
- $savedService = ServiceApplication::firstOrCreate([
- 'name' => $serviceName,
- 'service_id' => $resource->id,
- ], [
- 'is_gzip_enabled' => true,
- ]);
- }
- // Check if image changed
- if ($savedService->image !== $image) {
- $savedService->image = $image;
- $savedService->save();
- }
- // Pocketbase does not need gzip for SSE.
- if (str($savedService->image)->contains('pocketbase') && $savedService->is_gzip_enabled) {
- $savedService->is_gzip_enabled = false;
- $savedService->save();
- }
- }
-
- $environment = collect(data_get($service, 'environment', []));
- $buildArgs = collect(data_get($service, 'build.args', []));
- $environment = $environment->merge($buildArgs);
-
- // convert environment variables to one format
- $environment = convertToKeyValueCollection($environment);
-
- // Add Coolify defined environments
- $allEnvironments = $resource->environment_variables()->get(['key', 'value']);
-
- $allEnvironments = $allEnvironments->mapWithKeys(function ($item) {
- return [$item['key'] => $item['value']];
- });
- // filter and add magic environments
- foreach ($environment as $key => $value) {
- // Get all SERVICE_ variables from keys and values
- $key = str($key);
- $value = str($value);
- $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
- preg_match_all($regex, $value, $valueMatches);
- if (count($valueMatches[1]) > 0) {
- foreach ($valueMatches[1] as $match) {
- $match = replaceVariables($match);
- if ($match->startsWith('SERVICE_')) {
- if ($magicEnvironments->has($match->value())) {
- continue;
- }
- $magicEnvironments->put($match->value(), '');
- }
- }
- }
- // Get magic environments where we need to preset the FQDN
- if ($key->startsWith('SERVICE_FQDN_')) {
- // SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
- if (substr_count(str($key)->value(), '_') === 3) {
- $fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
- $port = $key->afterLast('_')->value();
- } else {
- $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
- $port = null;
- }
- if ($isApplication) {
- $fqdn = $resource->fqdn;
- if (blank($resource->fqdn)) {
- $fqdn = generateFqdn($server, "$uuid");
- }
- } elseif ($isService) {
- if (blank($savedService->fqdn)) {
- if ($fqdnFor) {
- $fqdn = generateFqdn($server, "$fqdnFor-$uuid");
- } else {
- $fqdn = generateFqdn($server, "{$savedService->name}-$uuid");
- }
- } else {
- $fqdn = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
- }
- }
-
- if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
- $path = $value->value();
- if ($path !== '/') {
- $fqdn = "$fqdn$path";
- }
- }
- $fqdnWithPort = $fqdn;
- if ($port) {
- $fqdnWithPort = "$fqdn:$port";
- }
- if ($isApplication && is_null($resource->fqdn)) {
- data_forget($resource, 'environment_variables');
- data_forget($resource, 'environment_variables_preview');
- $resource->fqdn = $fqdnWithPort;
- $resource->save();
- } elseif ($isService && is_null($savedService->fqdn)) {
- $savedService->fqdn = $fqdnWithPort;
- $savedService->save();
- }
-
- if (substr_count(str($key)->value(), '_') === 2) {
- $resource->environment_variables()->updateOrCreate([
- 'key' => $key->value(),
- 'resourceable_type' => get_class($resource),
- 'resourceable_id' => $resource->id,
- ], [
- 'value' => $fqdn,
- 'is_build_time' => false,
- 'is_preview' => false,
- ]);
- }
- if (substr_count(str($key)->value(), '_') === 3) {
- $newKey = str($key)->beforeLast('_');
- $resource->environment_variables()->updateOrCreate([
- 'key' => $newKey->value(),
- 'resourceable_type' => get_class($resource),
- 'resourceable_id' => $resource->id,
- ], [
- 'value' => $fqdn,
- 'is_build_time' => false,
- 'is_preview' => false,
- ]);
- }
- }
- }
-
- $allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments);
- if ($magicEnvironments->count() > 0) {
- foreach ($magicEnvironments as $key => $value) {
- $key = str($key);
- $value = replaceVariables($value);
- $command = parseCommandFromMagicEnvVariable($key);
- $found = $resource->environment_variables()->where('key', $key->value())->where('resourceable_type', get_class($resource))->where('resourceable_id', $resource->id)->first();
- if ($found) {
- continue;
- }
- if ($command->value() === 'FQDN') {
- if ($isApplication && $resource->build_pack === 'dockercompose') {
- continue;
- }
- $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
- if (str($fqdnFor)->contains('_')) {
- $fqdnFor = str($fqdnFor)->before('_');
- }
- $fqdn = generateFqdn($server, "$fqdnFor-$uuid");
- $resource->environment_variables()->firstOrCreate([
- 'key' => $key->value(),
- 'resourceable_type' => get_class($resource),
- 'resourceable_id' => $resource->id,
- ], [
- 'value' => $fqdn,
- 'is_build_time' => false,
- 'is_preview' => false,
- ]);
- } elseif ($command->value() === 'URL') {
- if ($isApplication && $resource->build_pack === 'dockercompose') {
- continue;
- }
- // For services, only generate URL if explicit FQDN is set
- if ($isService && blank($savedService->fqdn)) {
- continue;
- }
- $fqdnFor = $key->after('SERVICE_URL_')->lower()->value();
- if (str($fqdnFor)->contains('_')) {
- $fqdnFor = str($fqdnFor)->before('_');
- }
- $fqdn = generateFqdn($server, "$fqdnFor-$uuid");
- $fqdn = str($fqdn)->replace('http://', '')->replace('https://', '');
- $resource->environment_variables()->firstOrCreate([
- 'key' => $key->value(),
- 'resourceable_type' => get_class($resource),
- 'resourceable_id' => $resource->id,
- ], [
- 'value' => $fqdn,
- 'is_build_time' => false,
- 'is_preview' => false,
- ]);
- } else {
- $value = generateEnvValue($command, $resource);
- $resource->environment_variables()->firstOrCreate([
- 'key' => $key->value(),
- 'resourceable_type' => get_class($resource),
- 'resourceable_id' => $resource->id,
- ], [
- 'value' => $value,
- 'is_build_time' => false,
- 'is_preview' => false,
- ]);
- }
- }
- }
- }
-
- $serviceAppsLogDrainEnabledMap = collect([]);
- if ($resource instanceof Service) {
- $serviceAppsLogDrainEnabledMap = $resource->applications()->get()->keyBy('name')->map(function ($app) {
- return $app->isLogDrainEnabled();
- });
- }
-
- // Parse the rest of the services
- foreach ($services as $serviceName => $service) {
- $image = data_get_str($service, 'image');
- $restart = data_get_str($service, 'restart', RESTART_MODE);
- $logging = data_get($service, 'logging');
-
- if ($server->isLogDrainEnabled()) {
- if ($resource instanceof Application && $resource->isLogDrainEnabled()) {
- $logging = generate_fluentd_configuration();
- }
- if ($resource instanceof Service && $serviceAppsLogDrainEnabledMap->get($serviceName)) {
- $logging = generate_fluentd_configuration();
- }
- }
- $volumes = collect(data_get($service, 'volumes', []));
- $networks = collect(data_get($service, 'networks', []));
- $use_network_mode = data_get($service, 'network_mode') !== null;
- $depends_on = collect(data_get($service, 'depends_on', []));
- $labels = collect(data_get($service, 'labels', []));
- if ($labels->count() > 0) {
- if (isAssociativeArray($labels)) {
- $newLabels = collect([]);
- $labels->each(function ($value, $key) use ($newLabels) {
- $newLabels->push("$key=$value");
- });
- $labels = $newLabels;
- }
- }
- $environment = collect(data_get($service, 'environment', []));
- $ports = collect(data_get($service, 'ports', []));
- $buildArgs = collect(data_get($service, 'build.args', []));
- $environment = $environment->merge($buildArgs);
-
- $environment = convertToKeyValueCollection($environment);
- $coolifyEnvironments = collect([]);
-
- $isDatabase = isDatabaseImage($image, $service);
- $volumesParsed = collect([]);
-
- if ($isApplication) {
- $baseName = generateApplicationContainerName(
- application: $resource,
- pull_request_id: $pullRequestId
- );
- $containerName = "$serviceName-$baseName";
- $predefinedPort = null;
- } elseif ($isService) {
- $containerName = "$serviceName-{$resource->uuid}";
-
- if ($serviceName === 'registry') {
- $tempServiceName = 'docker-registry';
- } else {
- $tempServiceName = $serviceName;
- }
- if (str(data_get($service, 'image'))->contains('glitchtip')) {
- $tempServiceName = 'glitchtip';
- }
- if ($serviceName === 'supabase-kong') {
- $tempServiceName = 'supabase';
- }
- $serviceDefinition = data_get($allServices, $tempServiceName);
- $predefinedPort = data_get($serviceDefinition, 'port');
- if ($serviceName === 'plausible') {
- $predefinedPort = '8000';
- }
-
- if ($isDatabase) {
- $applicationFound = ServiceApplication::where('name', $serviceName)->where('image', $image)->where('service_id', $resource->id)->first();
- if ($applicationFound) {
- $savedService = $applicationFound;
- // $savedService = ServiceDatabase::firstOrCreate([
- // 'name' => $applicationFound->name,
- // 'image' => $applicationFound->image,
- // 'service_id' => $applicationFound->service_id,
- // ]);
- // $applicationFound->delete();
- } else {
- $savedService = ServiceDatabase::firstOrCreate([
- 'name' => $serviceName,
- 'image' => $image,
- 'service_id' => $resource->id,
- ]);
- }
- } else {
- $savedService = ServiceApplication::firstOrCreate([
- 'name' => $serviceName,
- 'image' => $image,
- 'service_id' => $resource->id,
- ]);
- }
- $fileStorages = $savedService->fileStorages();
- if ($savedService->image !== $image) {
- $savedService->image = $image;
- $savedService->save();
- }
- }
-
- $originalResource = $isApplication ? $resource : $savedService;
-
- if ($volumes->count() > 0) {
- foreach ($volumes as $index => $volume) {
- $type = null;
- $source = null;
- $target = null;
- $content = null;
- $isDirectory = false;
- if (is_string($volume)) {
- $source = str($volume)->before(':');
- $target = str($volume)->after(':')->beforeLast(':');
- $foundConfig = $fileStorages->whereMountPath($target)->first();
- if (sourceIsLocal($source)) {
- $type = str('bind');
- if ($foundConfig) {
- $contentNotNull_temp = data_get($foundConfig, 'content');
- if ($contentNotNull_temp) {
- $content = $contentNotNull_temp;
- }
- $isDirectory = data_get($foundConfig, 'is_directory');
- } else {
- // By default, we cannot determine if the bind is a directory or not, so we set it to directory
- $isDirectory = true;
- }
- } else {
- $type = str('volume');
- }
- } elseif (is_array($volume)) {
- $type = data_get_str($volume, 'type');
- $source = data_get_str($volume, 'source');
- $target = data_get_str($volume, 'target');
- $content = data_get($volume, 'content');
- $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
-
- $foundConfig = $fileStorages->whereMountPath($target)->first();
- if ($foundConfig) {
- $contentNotNull_temp = data_get($foundConfig, 'content');
- if ($contentNotNull_temp) {
- $content = $contentNotNull_temp;
- }
- $isDirectory = data_get($foundConfig, 'is_directory');
- } else {
- // if isDirectory is not set (or false) & content is also not set, we assume it is a directory
- if ((is_null($isDirectory) || ! $isDirectory) && is_null($content)) {
- $isDirectory = true;
- }
- }
- }
- if ($type->value() === 'bind') {
- if ($source->value() === '/var/run/docker.sock') {
- $volume = $source->value().':'.$target->value();
- } elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') {
- $volume = $source->value().':'.$target->value();
- } else {
- if ((int) $resource->compose_parsing_version >= 4) {
- if ($isApplication) {
- $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
- } elseif ($isService) {
- $mainDirectory = str(base_configuration_dir().'/services/'.$uuid);
- }
- } else {
- $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
- }
- $source = replaceLocalSource($source, $mainDirectory);
- if ($isApplication && $isPullRequest) {
- $source = $source."-pr-$pullRequestId";
- }
- LocalFileVolume::updateOrCreate(
- [
- 'mount_path' => $target,
- 'resource_id' => $originalResource->id,
- 'resource_type' => get_class($originalResource),
- ],
- [
- 'fs_path' => $source,
- 'mount_path' => $target,
- 'content' => $content,
- 'is_directory' => $isDirectory,
- 'resource_id' => $originalResource->id,
- 'resource_type' => get_class($originalResource),
- ]
- );
- if (isDev()) {
- if ((int) $resource->compose_parsing_version >= 4) {
- if ($isApplication) {
- $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid);
- } elseif ($isService) {
- $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/services/'.$uuid);
- }
- } else {
- $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid);
- }
- }
- $volume = "$source:$target";
- }
- } elseif ($type->value() === 'volume') {
- if ($topLevel->get('volumes')->has($source->value())) {
- $temp = $topLevel->get('volumes')->get($source->value());
- if (data_get($temp, 'driver_opts.type') === 'cifs') {
- continue;
- }
- if (data_get($temp, 'driver_opts.type') === 'nfs') {
- continue;
- }
- }
- $slugWithoutUuid = Str::slug($source, '-');
- $name = "{$uuid}_{$slugWithoutUuid}";
-
- if ($isApplication && $isPullRequest) {
- $name = "{$name}-pr-$pullRequestId";
- }
- if (is_string($volume)) {
- $source = str($volume)->before(':');
- $target = str($volume)->after(':')->beforeLast(':');
- $source = $name;
- $volume = "$source:$target";
- } elseif (is_array($volume)) {
- data_set($volume, 'source', $name);
- }
- $topLevel->get('volumes')->put($name, [
- 'name' => $name,
- ]);
- LocalPersistentVolume::updateOrCreate(
- [
- 'name' => $name,
- 'resource_id' => $originalResource->id,
- 'resource_type' => get_class($originalResource),
- ],
- [
- 'name' => $name,
- 'mount_path' => $target,
- 'resource_id' => $originalResource->id,
- 'resource_type' => get_class($originalResource),
- ]
- );
- }
- dispatch(new ServerFilesFromServerJob($originalResource));
- $volumesParsed->put($index, $volume);
- }
- }
-
- if ($depends_on?->count() > 0) {
- if ($isApplication && $isPullRequest) {
- $newDependsOn = collect([]);
- $depends_on->each(function ($dependency, $condition) use ($pullRequestId, $newDependsOn) {
- if (is_numeric($condition)) {
- $dependency = "$dependency-pr-$pullRequestId";
-
- $newDependsOn->put($condition, $dependency);
- } else {
- $condition = "$condition-pr-$pullRequestId";
- $newDependsOn->put($condition, $dependency);
- }
- });
- $depends_on = $newDependsOn;
- }
- }
- if (! $use_network_mode) {
- if ($topLevel->get('networks')?->count() > 0) {
- foreach ($topLevel->get('networks') as $networkName => $network) {
- if ($networkName === 'default') {
- continue;
- }
- // ignore aliases
- if ($network['aliases'] ?? false) {
- continue;
- }
- $networkExists = $networks->contains(function ($value, $key) use ($networkName) {
- return $value == $networkName || $key == $networkName;
- });
- if (! $networkExists) {
- $networks->put($networkName, null);
- }
- }
- }
- $baseNetworkExists = $networks->contains(function ($value, $_) use ($baseNetwork) {
- return $value == $baseNetwork;
- });
- if (! $baseNetworkExists) {
- foreach ($baseNetwork as $network) {
- $topLevel->get('networks')->put($network, [
- 'name' => $network,
- 'external' => true,
- ]);
- }
- }
- }
-
- // Collect/create/update ports
- $collectedPorts = collect([]);
- if ($ports->count() > 0) {
- foreach ($ports as $sport) {
- if (is_string($sport) || is_numeric($sport)) {
- $collectedPorts->push($sport);
- }
- if (is_array($sport)) {
- $target = data_get($sport, 'target');
- $published = data_get($sport, 'published');
- $protocol = data_get($sport, 'protocol');
- $collectedPorts->push("$target:$published/$protocol");
- }
- }
- }
- if ($isService) {
- $originalResource->ports = $collectedPorts->implode(',');
- $originalResource->save();
- }
-
- $networks_temp = collect();
-
- if (! $use_network_mode) {
- foreach ($networks as $key => $network) {
- if (gettype($network) === 'string') {
- // networks:
- // - appwrite
- $networks_temp->put($network, null);
- } elseif (gettype($network) === 'array') {
- // networks:
- // default:
- // ipv4_address: 192.168.203.254
- $networks_temp->put($key, $network);
- }
- }
- foreach ($baseNetwork as $key => $network) {
- $networks_temp->put($network, null);
- }
-
- if ($isApplication) {
- if (data_get($resource, 'settings.connect_to_docker_network')) {
- $network = $resource->destination->network;
- $networks_temp->put($network, null);
- $topLevel->get('networks')->put($network, [
- 'name' => $network,
- 'external' => true,
- ]);
- }
- }
- }
-
- $normalEnvironments = $environment->diffKeys($allMagicEnvironments);
- $normalEnvironments = $normalEnvironments->filter(function ($value, $key) {
- return ! str($value)->startsWith('SERVICE_');
- });
-
- foreach ($normalEnvironments as $key => $value) {
- $key = str($key);
- $value = str($value);
- $originalValue = $value;
- $parsedValue = replaceVariables($value);
- if ($value->startsWith('$SERVICE_')) {
- $resource->environment_variables()->firstOrCreate([
- 'key' => $key,
- 'resourceable_type' => get_class($resource),
- 'resourceable_id' => $resource->id,
- ], [
- 'value' => $value,
- 'is_build_time' => false,
- 'is_preview' => false,
- ]);
-
- continue;
- }
- if (! $value->startsWith('$')) {
- continue;
- }
- if ($key->value() === $parsedValue->value()) {
- $value = null;
- $resource->environment_variables()->firstOrCreate([
- 'key' => $key,
- 'resourceable_type' => get_class($resource),
- 'resourceable_id' => $resource->id,
- ], [
- 'value' => $value,
- 'is_build_time' => false,
- 'is_preview' => false,
- ]);
- } else {
- if ($value->startsWith('$')) {
- $isRequired = false;
- if ($value->contains(':-')) {
- $value = replaceVariables($value);
- $key = $value->before(':');
- $value = $value->after(':-');
- } elseif ($value->contains('-')) {
- $value = replaceVariables($value);
-
- $key = $value->before('-');
- $value = $value->after('-');
- } elseif ($value->contains(':?')) {
- $value = replaceVariables($value);
-
- $key = $value->before(':');
- $value = $value->after(':?');
- $isRequired = true;
- } elseif ($value->contains('?')) {
- $value = replaceVariables($value);
-
- $key = $value->before('?');
- $value = $value->after('?');
- $isRequired = true;
- }
- if ($originalValue->value() === $value->value()) {
- // This means the variable does not have a default value, so it needs to be created in Coolify
- $parsedKeyValue = replaceVariables($value);
- $resource->environment_variables()->firstOrCreate([
- 'key' => $parsedKeyValue,
- 'resourceable_type' => get_class($resource),
- 'resourceable_id' => $resource->id,
- ], [
- 'is_build_time' => false,
- 'is_preview' => false,
- 'is_required' => $isRequired,
- ]);
- // Add the variable to the environment so it will be shown in the deployable compose file
- // $environment[$parsedKeyValue->value()] = $resource->environment_variables()->where('key', $parsedKeyValue)->where('resourceable_type', get_class($resource))->where('resourceable_id', $resource->id)->first()->real_value;
- $environment[$parsedKeyValue->value()] = $value;
-
- continue;
- }
- $resource->environment_variables()->firstOrCreate([
- 'key' => $key,
- 'resourceable_type' => get_class($resource),
- 'resourceable_id' => $resource->id,
- ], [
- 'value' => $value,
- 'is_build_time' => false,
- 'is_preview' => false,
- 'is_required' => $isRequired,
- ]);
- }
- }
- }
- if ($isApplication) {
- $branch = $originalResource->git_branch;
- if ($pullRequestId !== 0) {
- $branch = "pull/{$pullRequestId}/head";
- }
- if ($originalResource->environment_variables->where('key', 'COOLIFY_BRANCH')->isEmpty()) {
- $coolifyEnvironments->put('COOLIFY_BRANCH', "\"{$branch}\"");
- }
- }
-
- // Add COOLIFY_RESOURCE_UUID to environment
- if ($resource->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
- $coolifyEnvironments->put('COOLIFY_RESOURCE_UUID', "{$resource->uuid}");
- }
-
- // Add COOLIFY_CONTAINER_NAME to environment
- if ($resource->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
- $coolifyEnvironments->put('COOLIFY_CONTAINER_NAME', "{$containerName}");
- }
-
- if ($isApplication) {
- if ($isPullRequest) {
- $preview = $resource->previews()->find($preview_id);
- $domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))) ?? collect([]);
- } else {
- $domains = collect(json_decode($resource->docker_compose_domains)) ?? collect([]);
- }
- $fqdns = data_get($domains, "$serviceName.domain");
- // Generate SERVICE_FQDN & SERVICE_URL for dockercompose
- if ($resource->build_pack === 'dockercompose') {
- foreach ($domains as $forServiceName => $domain) {
- $parsedDomain = data_get($domain, 'domain');
- if (filled($parsedDomain)) {
- $parsedDomain = str($parsedDomain)->explode(',')->first();
- $coolifyUrl = Url::fromString($parsedDomain);
- $coolifyScheme = $coolifyUrl->getScheme();
- $coolifyFqdn = $coolifyUrl->getHost();
- $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
- $coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_'), $coolifyUrl->__toString());
- $coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_'), $coolifyFqdn);
- }
- }
- }
- // If the domain is set, we need to generate the FQDNs for the preview
- if (filled($fqdns)) {
- $fqdns = str($fqdns)->explode(',');
- if ($isPullRequest) {
- $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");
- if ($found_fqdn) {
- $fqdns = collect($found_fqdn);
- } else {
- $fqdns = collect([]);
- }
- } else {
- $fqdns = $fqdns->map(function ($fqdn) use ($pullRequestId, $resource) {
- $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pullRequestId);
- $url = Url::fromString($fqdn);
- $template = $resource->preview_url_template;
- $host = $url->getHost();
- $schema = $url->getScheme();
- $random = new Cuid2;
- $preview_fqdn = str_replace('{{random}}', $random, $template);
- $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
- $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
- $preview_fqdn = "$schema://$preview_fqdn";
- $preview->fqdn = $preview_fqdn;
- $preview->save();
-
- return $preview_fqdn;
- });
- }
- }
- }
- $defaultLabels = defaultLabels(
- id: $resource->id,
- name: $containerName,
- projectName: $resource->project()->name,
- resourceName: $resource->name,
- pull_request_id: $pullRequestId,
- type: 'application',
- environment: $resource->environment->name,
- );
-
- } elseif ($isService) {
- if ($savedService->serviceType()) {
- $fqdns = generateServiceSpecificFqdns($savedService);
- } else {
- $fqdns = collect(data_get($savedService, 'fqdns'))->filter();
- }
-
- $defaultLabels = defaultLabels(
- id: $resource->id,
- name: $containerName,
- projectName: $resource->project()->name,
- resourceName: $resource->name,
- type: 'service',
- subType: $isDatabase ? 'database' : 'application',
- subId: $savedService->id,
- subName: $savedService->human_name ?? $savedService->name,
- environment: $resource->environment->name,
- );
- }
- // Add COOLIFY_FQDN & COOLIFY_URL to environment
- if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
- $fqdnsWithoutPort = $fqdns->map(function ($fqdn) {
- return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://'));
- });
- $coolifyEnvironments->put('COOLIFY_URL', $fqdnsWithoutPort->implode(','));
-
- $urls = $fqdns->map(function ($fqdn) {
- return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':');
- });
- $coolifyEnvironments->put('COOLIFY_FQDN', $urls->implode(','));
- }
- add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
-
- if ($environment->count() > 0) {
- $environment = $environment->filter(function ($value, $key) {
- return ! str($key)->startsWith('SERVICE_FQDN_');
- })->map(function ($value, $key) use ($resource) {
- // if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used
- if (str($value)->isEmpty()) {
- if ($resource->environment_variables()->where('key', $key)->exists()) {
- $value = $resource->environment_variables()->where('key', $key)->first()->value;
- } else {
- $value = null;
- }
- }
-
- return $value;
- });
- }
- $serviceLabels = $labels->merge($defaultLabels);
- if ($serviceLabels->count() > 0) {
- if ($isApplication) {
- $isContainerLabelEscapeEnabled = data_get($resource, 'settings.is_container_label_escape_enabled');
- } else {
- $isContainerLabelEscapeEnabled = data_get($resource, 'is_container_label_escape_enabled');
- }
- if ($isContainerLabelEscapeEnabled) {
- $serviceLabels = $serviceLabels->map(function ($value, $key) {
- return escapeDollarSign($value);
- });
- }
- }
- if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
- if ($isApplication) {
- $shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;
- $uuid = $resource->uuid;
- $network = data_get($resource, 'destination.network');
- if ($isPullRequest) {
- $uuid = "{$resource->uuid}-{$pullRequestId}";
- }
- if ($isPullRequest) {
- $network = "{$resource->destination->network}-{$pullRequestId}";
- }
- } else {
- $shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels;
- $uuid = $resource->uuid;
- $network = data_get($resource, 'destination.network');
- }
- if ($shouldGenerateLabelsExactly) {
- switch ($server->proxyType()) {
- case ProxyTypes::TRAEFIK->value:
- $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
- uuid: $uuid,
- domains: $fqdns,
- is_force_https_enabled: true,
- serviceLabels: $serviceLabels,
- is_gzip_enabled: $originalResource->isGzipEnabled(),
- is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
- service_name: $serviceName,
- image: $image
- ));
- break;
- case ProxyTypes::CADDY->value:
- $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
- network: $network,
- uuid: $uuid,
- domains: $fqdns,
- is_force_https_enabled: true,
- serviceLabels: $serviceLabels,
- is_gzip_enabled: $originalResource->isGzipEnabled(),
- is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
- service_name: $serviceName,
- image: $image,
- predefinedPort: $predefinedPort
- ));
- break;
- }
- } else {
- $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
- uuid: $uuid,
- domains: $fqdns,
- is_force_https_enabled: true,
- serviceLabels: $serviceLabels,
- is_gzip_enabled: $originalResource->isGzipEnabled(),
- is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
- service_name: $serviceName,
- image: $image
- ));
- $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
- network: $network,
- uuid: $uuid,
- domains: $fqdns,
- is_force_https_enabled: true,
- serviceLabels: $serviceLabels,
- is_gzip_enabled: $originalResource->isGzipEnabled(),
- is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
- service_name: $serviceName,
- image: $image,
- predefinedPort: $predefinedPort
- ));
- }
- }
- if ($isService) {
- if (data_get($service, 'restart') === 'no' || data_get($service, 'exclude_from_hc')) {
- $savedService->update(['exclude_from_status' => true]);
- }
- }
- data_forget($service, 'volumes.*.content');
- data_forget($service, 'volumes.*.isDirectory');
- data_forget($service, 'volumes.*.is_directory');
- data_forget($service, 'exclude_from_hc');
-
- $volumesParsed = $volumesParsed->map(function ($volume) {
- data_forget($volume, 'content');
- data_forget($volume, 'is_directory');
- data_forget($volume, 'isDirectory');
-
- return $volume;
- });
-
- $payload = collect($service)->merge([
- 'container_name' => $containerName,
- 'restart' => $restart->value(),
- 'labels' => $serviceLabels,
- ]);
- if (! $use_network_mode) {
- $payload['networks'] = $networks_temp;
- }
- if ($ports->count() > 0) {
- $payload['ports'] = $ports;
- }
- if ($volumesParsed->count() > 0) {
- $payload['volumes'] = $volumesParsed;
- }
- if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) {
- $payload['environment'] = $environment->merge($coolifyEnvironments);
- }
- if ($logging) {
- $payload['logging'] = $logging;
- }
- if ($depends_on->count() > 0) {
- $payload['depends_on'] = $depends_on;
- }
- if ($isApplication && $isPullRequest) {
- $serviceName = "{$serviceName}-pr-{$pullRequestId}";
- }
-
- $parsedServices->put($serviceName, $payload);
- }
- $topLevel->put('services', $parsedServices);
-
- $customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets'];
-
- $topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) {
- return array_search($key, $customOrder);
- });
-
- $resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2);
- data_forget($resource, 'environment_variables');
- data_forget($resource, 'environment_variables_preview');
- $resource->save();
-
- return $topLevel;
-}
-
function generate_fluentd_configuration(): array
{
return [
@@ -4288,3 +3047,18 @@ function parseDockerfileInterval(string $something)
return $seconds;
}
+
+function addPreviewDeploymentSuffix(string $name, int $pull_request_id = 0): string
+{
+ return ($pull_request_id === 0) ? $name : $name.'-pr-'.$pull_request_id;
+}
+
+function generateDockerComposeServiceName(mixed $services, int $pullRequestId = 0): Collection
+{
+ $collection = collect([]);
+ foreach ($services as $serviceName => $_) {
+ $collection->put('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper(), addPreviewDeploymentSuffix($serviceName, $pullRequestId));
+ }
+
+ return $collection;
+}
diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php
index 510516a2f..48c3a62c3 100644
--- a/bootstrap/helpers/subscriptions.php
+++ b/bootstrap/helpers/subscriptions.php
@@ -89,3 +89,22 @@ function allowedPathsForInvalidAccounts()
'livewire/update',
];
}
+
+function updateStripeCustomerEmail(Team $team, string $newEmail): void
+{
+ if (! isStripe()) {
+ return;
+ }
+
+ $stripe_customer_id = data_get($team, 'subscription.stripe_customer_id');
+ if (! $stripe_customer_id) {
+ return;
+ }
+
+ Stripe::setApiKey(config('subscription.stripe_api_key'));
+
+ \Stripe\Customer::update(
+ $stripe_customer_id,
+ ['email' => $newEmail]
+ );
+}
diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php
new file mode 100644
index 000000000..ba252c64f
--- /dev/null
+++ b/bootstrap/helpers/sudo.php
@@ -0,0 +1,101 @@
+map(function ($line) {
+ if (
+ ! str(trim($line))->startsWith([
+ 'cd',
+ 'command',
+ 'echo',
+ 'true',
+ 'if',
+ 'fi',
+ ])
+ ) {
+ return "sudo $line";
+ }
+
+ if (str(trim($line))->startsWith('if')) {
+ return str_replace('if', 'if sudo', $line);
+ }
+
+ return $line;
+ });
+
+ $commands = $commands->map(function ($line) use ($server) {
+ if (Str::startsWith($line, 'sudo mkdir -p')) {
+ $path = trim(Str::after($line, 'sudo mkdir -p'));
+ if (shouldChangeOwnership($path)) {
+ return "$line && sudo chown -R $server->user:$server->user $path && sudo chmod -R o-rwx $path";
+ }
+
+ return $line;
+ }
+
+ return $line;
+ });
+
+ $commands = $commands->map(function ($line) {
+ $line = str($line);
+ if (str($line)->contains('$(')) {
+ $line = $line->replace('$(', '$(sudo ');
+ }
+ if (str($line)->contains('||')) {
+ $line = $line->replace('||', '|| sudo');
+ }
+ if (str($line)->contains('&&')) {
+ $line = $line->replace('&&', '&& sudo');
+ }
+ if (str($line)->contains(' | ')) {
+ $line = $line->replace(' | ', ' | sudo ');
+ }
+
+ return $line->value();
+ });
+
+ return $commands->toArray();
+}
+function parseLineForSudo(string $command, Server $server): string
+{
+ if (! str($command)->startSwith('cd') && ! str($command)->startSwith('command')) {
+ $command = "sudo $command";
+ }
+ if (Str::startsWith($command, 'sudo mkdir -p')) {
+ $path = trim(Str::after($command, 'sudo mkdir -p'));
+ if (shouldChangeOwnership($path)) {
+ $command = "$command && sudo chown -R $server->user:$server->user $path && sudo chmod -R o-rwx $path";
+ }
+ }
+ if (str($command)->contains('$(') || str($command)->contains('`')) {
+ $command = str($command)->replace('$(', '$(sudo ')->replace('`', '`sudo ')->value();
+ }
+ if (str($command)->contains('||')) {
+ $command = str($command)->replace('||', '|| sudo ')->value();
+ }
+ if (str($command)->contains('&&')) {
+ $command = str($command)->replace('&&', '&& sudo ')->value();
+ }
+
+ return $command;
+}
diff --git a/changelogs/.gitignore b/changelogs/.gitignore
new file mode 100644
index 000000000..d6b7ef32c
--- /dev/null
+++ b/changelogs/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/composer.json b/composer.json
index 68b0fb066..ea466049d 100644
--- a/composer.json
+++ b/composer.json
@@ -47,6 +47,7 @@
"socialiteproviders/zitadel": "^4.2",
"spatie/laravel-activitylog": "^4.10.2",
"spatie/laravel-data": "^4.17.0",
+ "spatie/laravel-markdown": "^2.7",
"spatie/laravel-ray": "^1.40.2",
"spatie/laravel-schemaless-attributes": "^2.5.1",
"spatie/url": "^2.4",
@@ -61,6 +62,7 @@
"barryvdh/laravel-debugbar": "^3.15.4",
"driftingly/rector-laravel": "^2.0.5",
"fakerphp/faker": "^1.24.1",
+ "laravel/boost": "^1.1",
"laravel/dusk": "^8.3.3",
"laravel/pint": "^1.24",
"laravel/telescope": "^5.10",
@@ -127,4 +129,4 @@
"@php artisan key:generate --ansi"
]
}
-}
\ No newline at end of file
+}
diff --git a/composer.lock b/composer.lock
index 8d170cdc1..6320db071 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "52a680a0eb446dcaa74bc35e158aca57",
+ "content-hash": "a993799242581bd06b5939005ee458d9",
"packages": [
{
"name": "amphp/amp",
@@ -870,16 +870,16 @@
},
{
"name": "aws/aws-sdk-php",
- "version": "3.351.1",
+ "version": "3.352.0",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
- "reference": "f3e20c8cdd2cc5827d77a0b3c0872fab89cdf805"
+ "reference": "7f3ad0da2545b24259273ea7ab892188bae7d91b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/f3e20c8cdd2cc5827d77a0b3c0872fab89cdf805",
- "reference": "f3e20c8cdd2cc5827d77a0b3c0872fab89cdf805",
+ "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7f3ad0da2545b24259273ea7ab892188bae7d91b",
+ "reference": "7f3ad0da2545b24259273ea7ab892188bae7d91b",
"shasum": ""
},
"require": {
@@ -961,9 +961,9 @@
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
- "source": "https://github.com/aws/aws-sdk-php/tree/3.351.1"
+ "source": "https://github.com/aws/aws-sdk-php/tree/3.352.0"
},
- "time": "2025-07-17T18:07:08+00:00"
+ "time": "2025-08-01T18:04:23+00:00"
},
{
"name": "bacon/bacon-qr-code",
@@ -1373,16 +1373,16 @@
},
{
"name": "doctrine/dbal",
- "version": "4.3.0",
+ "version": "4.3.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
- "reference": "5fe09532be619202d59c70956c6fb20e97933ee3"
+ "reference": "ac336c95ea9e13433d56ca81c308b39db0e1a2a7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/dbal/zipball/5fe09532be619202d59c70956c6fb20e97933ee3",
- "reference": "5fe09532be619202d59c70956c6fb20e97933ee3",
+ "url": "https://api.github.com/repos/doctrine/dbal/zipball/ac336c95ea9e13433d56ca81c308b39db0e1a2a7",
+ "reference": "ac336c95ea9e13433d56ca81c308b39db0e1a2a7",
"shasum": ""
},
"require": {
@@ -1459,7 +1459,7 @@
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
- "source": "https://github.com/doctrine/dbal/tree/4.3.0"
+ "source": "https://github.com/doctrine/dbal/tree/4.3.1"
},
"funding": [
{
@@ -1475,7 +1475,7 @@
"type": "tidelift"
}
],
- "time": "2025-06-16T19:31:04+00:00"
+ "time": "2025-07-22T10:09:51+00:00"
},
{
"name": "doctrine/deprecations",
@@ -2678,16 +2678,16 @@
},
{
"name": "laravel/framework",
- "version": "v12.20.0",
+ "version": "v12.21.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
- "reference": "1b9a00f8caf5503c92aa436279172beae1a484ff"
+ "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/framework/zipball/1b9a00f8caf5503c92aa436279172beae1a484ff",
- "reference": "1b9a00f8caf5503c92aa436279172beae1a484ff",
+ "url": "https://api.github.com/repos/laravel/framework/zipball/ac8c4e73bf1b5387b709f7736d41427e6af1c93b",
+ "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b",
"shasum": ""
},
"require": {
@@ -2889,7 +2889,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
- "time": "2025-07-08T15:02:21+00:00"
+ "time": "2025-07-22T15:41:55+00:00"
},
{
"name": "laravel/horizon",
@@ -3111,16 +3111,16 @@
},
{
"name": "laravel/sanctum",
- "version": "v4.1.2",
+ "version": "v4.2.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/sanctum.git",
- "reference": "e4c09e69aecd5a383e0c1b85a6bb501c997d7491"
+ "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/sanctum/zipball/e4c09e69aecd5a383e0c1b85a6bb501c997d7491",
- "reference": "e4c09e69aecd5a383e0c1b85a6bb501c997d7491",
+ "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd6df4f79f48a72992e8d29a9c0ee25422a0d677",
+ "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677",
"shasum": ""
},
"require": {
@@ -3171,7 +3171,7 @@
"issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum"
},
- "time": "2025-07-01T15:49:32+00:00"
+ "time": "2025-07-09T19:45:24+00:00"
},
{
"name": "laravel/serializable-closure",
@@ -3236,16 +3236,16 @@
},
{
"name": "laravel/socialite",
- "version": "v5.21.0",
+ "version": "v5.23.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/socialite.git",
- "reference": "d83639499ad14985c9a6a9713b70073300ce998d"
+ "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/socialite/zipball/d83639499ad14985c9a6a9713b70073300ce998d",
- "reference": "d83639499ad14985c9a6a9713b70073300ce998d",
+ "url": "https://api.github.com/repos/laravel/socialite/zipball/e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5",
+ "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5",
"shasum": ""
},
"require": {
@@ -3304,7 +3304,7 @@
"issues": "https://github.com/laravel/socialite/issues",
"source": "https://github.com/laravel/socialite"
},
- "time": "2025-05-19T12:56:37+00:00"
+ "time": "2025-07-23T14:16:08+00:00"
},
{
"name": "laravel/tinker",
@@ -3510,16 +3510,16 @@
},
{
"name": "league/commonmark",
- "version": "2.7.0",
+ "version": "2.7.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
- "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405"
+ "reference": "10732241927d3971d28e7ea7b5712721fa2296ca"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6fbb36d44824ed4091adbcf4c7d4a3923cdb3405",
- "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405",
+ "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca",
+ "reference": "10732241927d3971d28e7ea7b5712721fa2296ca",
"shasum": ""
},
"require": {
@@ -3548,7 +3548,7 @@
"symfony/process": "^5.4 | ^6.0 | ^7.0",
"symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0",
"unleashedtech/php-coding-standard": "^3.1.1",
- "vimeo/psalm": "^4.24.0 || ^5.0.0"
+ "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0"
},
"suggest": {
"symfony/yaml": "v2.3+ required if using the Front Matter extension"
@@ -3613,7 +3613,7 @@
"type": "tidelift"
}
],
- "time": "2025-05-05T12:20:28+00:00"
+ "time": "2025-07-20T12:47:49+00:00"
},
{
"name": "league/config",
@@ -4696,16 +4696,16 @@
},
{
"name": "nesbot/carbon",
- "version": "3.10.1",
+ "version": "3.10.2",
"source": {
"type": "git",
"url": "https://github.com/CarbonPHP/carbon.git",
- "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00"
+ "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1fd1935b2d90aef2f093c5e35f7ae1257c448d00",
- "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00",
+ "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24",
+ "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24",
"shasum": ""
},
"require": {
@@ -4797,7 +4797,7 @@
"type": "tidelift"
}
],
- "time": "2025-06-21T15:19:35+00:00"
+ "time": "2025-08-02T09:36:06+00:00"
},
{
"name": "nette/schema",
@@ -4949,16 +4949,16 @@
},
{
"name": "nikic/php-parser",
- "version": "v5.5.0",
+ "version": "v5.6.0",
"source": {
"type": "git",
"url": "https://github.com/nikic/PHP-Parser.git",
- "reference": "ae59794362fe85e051a58ad36b289443f57be7a9"
+ "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9",
- "reference": "ae59794362fe85e051a58ad36b289443f57be7a9",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/221b0d0fdf1369c71047ad1d18bb5880017bbc56",
+ "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56",
"shasum": ""
},
"require": {
@@ -5001,9 +5001,9 @@
],
"support": {
"issues": "https://github.com/nikic/PHP-Parser/issues",
- "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0"
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.0"
},
- "time": "2025-05-31T08:24:38+00:00"
+ "time": "2025-07-27T20:03:57+00:00"
},
{
"name": "nubs/random-name-generator",
@@ -6663,16 +6663,16 @@
},
{
"name": "psy/psysh",
- "version": "v0.12.9",
+ "version": "v0.12.10",
"source": {
"type": "git",
"url": "https://github.com/bobthecow/psysh.git",
- "reference": "1b801844becfe648985372cb4b12ad6840245ace"
+ "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/bobthecow/psysh/zipball/1b801844becfe648985372cb4b12ad6840245ace",
- "reference": "1b801844becfe648985372cb4b12ad6840245ace",
+ "url": "https://api.github.com/repos/bobthecow/psysh/zipball/6e80abe6f2257121f1eb9a4c55bf29d921025b22",
+ "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22",
"shasum": ""
},
"require": {
@@ -6722,12 +6722,11 @@
"authors": [
{
"name": "Justin Hileman",
- "email": "justin@justinhileman.info",
- "homepage": "http://justinhileman.com"
+ "email": "justin@justinhileman.info"
}
],
"description": "An interactive shell for modern PHP.",
- "homepage": "http://psysh.org",
+ "homepage": "https://psysh.org",
"keywords": [
"REPL",
"console",
@@ -6736,9 +6735,9 @@
],
"support": {
"issues": "https://github.com/bobthecow/psysh/issues",
- "source": "https://github.com/bobthecow/psysh/tree/v0.12.9"
+ "source": "https://github.com/bobthecow/psysh/tree/v0.12.10"
},
- "time": "2025-06-23T02:35:06+00:00"
+ "time": "2025-08-04T12:39:37+00:00"
},
{
"name": "purplepixie/phpdns",
@@ -7247,16 +7246,16 @@
},
{
"name": "sentry/sentry",
- "version": "4.14.1",
+ "version": "4.14.2",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-php.git",
- "reference": "a28c4a6f5fda2bf730789a638501d7a737a64eda"
+ "reference": "bfeec74303d60d3f8bc33701ab3e86f8a8729f17"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/a28c4a6f5fda2bf730789a638501d7a737a64eda",
- "reference": "a28c4a6f5fda2bf730789a638501d7a737a64eda",
+ "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/bfeec74303d60d3f8bc33701ab3e86f8a8729f17",
+ "reference": "bfeec74303d60d3f8bc33701ab3e86f8a8729f17",
"shasum": ""
},
"require": {
@@ -7320,7 +7319,7 @@
],
"support": {
"issues": "https://github.com/getsentry/sentry-php/issues",
- "source": "https://github.com/getsentry/sentry-php/tree/4.14.1"
+ "source": "https://github.com/getsentry/sentry-php/tree/4.14.2"
},
"funding": [
{
@@ -7332,7 +7331,7 @@
"type": "custom"
}
],
- "time": "2025-06-23T15:25:52+00:00"
+ "time": "2025-07-21T08:28:29+00:00"
},
{
"name": "sentry/sentry-laravel",
@@ -7903,6 +7902,66 @@
],
"time": "2025-05-08T15:41:09+00:00"
},
+ {
+ "name": "spatie/commonmark-shiki-highlighter",
+ "version": "2.5.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/spatie/commonmark-shiki-highlighter.git",
+ "reference": "595c7e0b45d4a63b17dfc1ccbd13532d431ec351"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/spatie/commonmark-shiki-highlighter/zipball/595c7e0b45d4a63b17dfc1ccbd13532d431ec351",
+ "reference": "595c7e0b45d4a63b17dfc1ccbd13532d431ec351",
+ "shasum": ""
+ },
+ "require": {
+ "league/commonmark": "^2.4.2",
+ "php": "^8.0",
+ "spatie/shiki-php": "^2.2.2",
+ "symfony/process": "^5.4|^6.4|^7.1"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^2.19|^v3.49.0",
+ "phpunit/phpunit": "^9.5",
+ "spatie/phpunit-snapshot-assertions": "^4.2.7",
+ "spatie/ray": "^1.28"
+ },
+ "type": "commonmark-extension",
+ "autoload": {
+ "psr-4": {
+ "Spatie\\CommonMarkShikiHighlighter\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Freek Van der Herten",
+ "email": "freek@spatie.be",
+ "role": "Developer"
+ }
+ ],
+ "description": "Highlight code blocks with league/commonmark and Shiki",
+ "homepage": "https://github.com/spatie/commonmark-shiki-highlighter",
+ "keywords": [
+ "commonmark-shiki-highlighter",
+ "spatie"
+ ],
+ "support": {
+ "source": "https://github.com/spatie/commonmark-shiki-highlighter/tree/2.5.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/spatie",
+ "type": "github"
+ }
+ ],
+ "time": "2025-01-13T11:25:47+00:00"
+ },
{
"name": "spatie/laravel-activitylog",
"version": "4.10.2",
@@ -8077,6 +8136,82 @@
],
"time": "2025-06-25T11:36:37+00:00"
},
+ {
+ "name": "spatie/laravel-markdown",
+ "version": "2.7.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/spatie/laravel-markdown.git",
+ "reference": "353e7f9fae62826e26cbadef58a12ecf39685280"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/spatie/laravel-markdown/zipball/353e7f9fae62826e26cbadef58a12ecf39685280",
+ "reference": "353e7f9fae62826e26cbadef58a12ecf39685280",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/cache": "^9.0|^10.0|^11.0|^12.0",
+ "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0",
+ "illuminate/support": "^9.0|^10.0|^11.0|^12.0",
+ "illuminate/view": "^9.0|^10.0|^11.0|^12.0",
+ "league/commonmark": "^2.6.0",
+ "php": "^8.1",
+ "spatie/commonmark-shiki-highlighter": "^2.5",
+ "spatie/laravel-package-tools": "^1.4.3"
+ },
+ "require-dev": {
+ "brianium/paratest": "^6.2|^7.8",
+ "nunomaduro/collision": "^5.3|^6.0|^7.0|^8.0",
+ "orchestra/testbench": "^6.15|^7.0|^8.0|^10.0",
+ "pestphp/pest": "^1.22|^2.0|^3.7",
+ "phpunit/phpunit": "^9.3|^11.5.3",
+ "spatie/laravel-ray": "^1.23",
+ "spatie/pest-plugin-snapshots": "^1.1|^2.2|^3.0",
+ "vimeo/psalm": "^4.8|^6.7"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Spatie\\LaravelMarkdown\\MarkdownServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Spatie\\LaravelMarkdown\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Freek Van der Herten",
+ "email": "freek@spatie.be",
+ "role": "Developer"
+ }
+ ],
+ "description": "A highly configurable markdown renderer and Blade component for Laravel",
+ "homepage": "https://github.com/spatie/laravel-markdown",
+ "keywords": [
+ "Laravel-Markdown",
+ "laravel",
+ "spatie"
+ ],
+ "support": {
+ "source": "https://github.com/spatie/laravel-markdown/tree/2.7.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/spatie",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-21T13:43:18+00:00"
+ },
{
"name": "spatie/laravel-package-tools",
"version": "1.92.7",
@@ -8516,6 +8651,71 @@
],
"time": "2025-04-18T08:17:40+00:00"
},
+ {
+ "name": "spatie/shiki-php",
+ "version": "2.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/spatie/shiki-php.git",
+ "reference": "a2e78a9ff8a1290b25d550be8fbf8285c13175c5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/spatie/shiki-php/zipball/a2e78a9ff8a1290b25d550be8fbf8285c13175c5",
+ "reference": "a2e78a9ff8a1290b25d550be8fbf8285c13175c5",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "php": "^8.0",
+ "symfony/process": "^5.4|^6.4|^7.1"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^v3.0",
+ "pestphp/pest": "^1.8",
+ "phpunit/phpunit": "^9.5",
+ "spatie/pest-plugin-snapshots": "^1.1",
+ "spatie/ray": "^1.10"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Spatie\\ShikiPhp\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Rias Van der Veken",
+ "email": "rias@spatie.be",
+ "role": "Developer"
+ },
+ {
+ "name": "Freek Van der Herten",
+ "email": "freek@spatie.be",
+ "role": "Developer"
+ }
+ ],
+ "description": "Highlight code using Shiki in PHP",
+ "homepage": "https://github.com/spatie/shiki-php",
+ "keywords": [
+ "shiki",
+ "spatie"
+ ],
+ "support": {
+ "source": "https://github.com/spatie/shiki-php/tree/2.3.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/spatie",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-21T14:16:57+00:00"
+ },
{
"name": "spatie/url",
"version": "2.4.0",
@@ -8779,16 +8979,16 @@
},
{
"name": "symfony/console",
- "version": "v7.3.1",
+ "version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101"
+ "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/9e27aecde8f506ba0fd1d9989620c04a87697101",
- "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101",
+ "url": "https://api.github.com/repos/symfony/console/zipball/5f360ebc65c55265a74d23d7fe27f957870158a1",
+ "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1",
"shasum": ""
},
"require": {
@@ -8853,7 +9053,7 @@
"terminal"
],
"support": {
- "source": "https://github.com/symfony/console/tree/v7.3.1"
+ "source": "https://github.com/symfony/console/tree/v7.3.2"
},
"funding": [
{
@@ -8864,12 +9064,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-06-27T19:55:54+00:00"
+ "time": "2025-07-30T17:13:41+00:00"
},
{
"name": "symfony/css-selector",
@@ -9005,16 +9209,16 @@
},
{
"name": "symfony/error-handler",
- "version": "v7.3.1",
+ "version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/error-handler.git",
- "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235"
+ "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/error-handler/zipball/35b55b166f6752d6aaf21aa042fc5ed280fce235",
- "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235",
+ "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3",
+ "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3",
"shasum": ""
},
"require": {
@@ -9062,7 +9266,7 @@
"description": "Provides tools to manage errors and ease debugging PHP code",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/error-handler/tree/v7.3.1"
+ "source": "https://github.com/symfony/error-handler/tree/v7.3.2"
},
"funding": [
{
@@ -9073,12 +9277,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-06-13T07:48:40+00:00"
+ "time": "2025-07-07T08:17:57+00:00"
},
{
"name": "symfony/event-dispatcher",
@@ -9238,16 +9446,16 @@
},
{
"name": "symfony/finder",
- "version": "v7.3.0",
+ "version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d"
+ "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/ec2344cf77a48253bbca6939aa3d2477773ea63d",
- "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe",
+ "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe",
"shasum": ""
},
"require": {
@@ -9282,7 +9490,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/finder/tree/v7.3.0"
+ "source": "https://github.com/symfony/finder/tree/v7.3.2"
},
"funding": [
{
@@ -9293,25 +9501,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-12-30T19:00:26+00:00"
+ "time": "2025-07-15T13:41:35+00:00"
},
{
"name": "symfony/http-foundation",
- "version": "v7.3.1",
+ "version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
- "reference": "23dd60256610c86a3414575b70c596e5deff6ed9"
+ "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-foundation/zipball/23dd60256610c86a3414575b70c596e5deff6ed9",
- "reference": "23dd60256610c86a3414575b70c596e5deff6ed9",
+ "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6877c122b3a6cc3695849622720054f6e6fa5fa6",
+ "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6",
"shasum": ""
},
"require": {
@@ -9361,7 +9573,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/http-foundation/tree/v7.3.1"
+ "source": "https://github.com/symfony/http-foundation/tree/v7.3.2"
},
"funding": [
{
@@ -9372,25 +9584,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-06-23T15:07:14+00:00"
+ "time": "2025-07-10T08:47:49+00:00"
},
{
"name": "symfony/http-kernel",
- "version": "v7.3.1",
+ "version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
- "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831"
+ "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1644879a66e4aa29c36fe33dfa6c54b450ce1831",
- "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831",
+ "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6ecc895559ec0097e221ed2fd5eb44d5fede083c",
+ "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c",
"shasum": ""
},
"require": {
@@ -9475,7 +9691,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/http-kernel/tree/v7.3.1"
+ "source": "https://github.com/symfony/http-kernel/tree/v7.3.2"
},
"funding": [
{
@@ -9486,25 +9702,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-06-28T08:24:55+00:00"
+ "time": "2025-07-31T10:45:04+00:00"
},
{
"name": "symfony/mailer",
- "version": "v7.3.1",
+ "version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/mailer.git",
- "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368"
+ "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/mailer/zipball/b5db5105b290bdbea5ab27b89c69effcf1cb3368",
- "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368",
+ "url": "https://api.github.com/repos/symfony/mailer/zipball/d43e84d9522345f96ad6283d5dfccc8c1cfc299b",
+ "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b",
"shasum": ""
},
"require": {
@@ -9555,7 +9775,7 @@
"description": "Helps sending emails",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/mailer/tree/v7.3.1"
+ "source": "https://github.com/symfony/mailer/tree/v7.3.2"
},
"funding": [
{
@@ -9566,25 +9786,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-06-27T19:55:54+00:00"
+ "time": "2025-07-15T11:36:08+00:00"
},
{
"name": "symfony/mime",
- "version": "v7.3.0",
+ "version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
- "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9"
+ "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/mime/zipball/0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9",
- "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9",
+ "url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1",
+ "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1",
"shasum": ""
},
"require": {
@@ -9639,7 +9863,7 @@
"mime-type"
],
"support": {
- "source": "https://github.com/symfony/mime/tree/v7.3.0"
+ "source": "https://github.com/symfony/mime/tree/v7.3.2"
},
"funding": [
{
@@ -9650,25 +9874,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-02-19T08:51:26+00:00"
+ "time": "2025-07-15T13:41:35+00:00"
},
{
"name": "symfony/options-resolver",
- "version": "v7.3.0",
+ "version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
- "reference": "afb9a8038025e5dbc657378bfab9198d75f10fca"
+ "reference": "119bcf13e67dbd188e5dbc74228b1686f66acd37"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/options-resolver/zipball/afb9a8038025e5dbc657378bfab9198d75f10fca",
- "reference": "afb9a8038025e5dbc657378bfab9198d75f10fca",
+ "url": "https://api.github.com/repos/symfony/options-resolver/zipball/119bcf13e67dbd188e5dbc74228b1686f66acd37",
+ "reference": "119bcf13e67dbd188e5dbc74228b1686f66acd37",
"shasum": ""
},
"require": {
@@ -9706,7 +9934,7 @@
"options"
],
"support": {
- "source": "https://github.com/symfony/options-resolver/tree/v7.3.0"
+ "source": "https://github.com/symfony/options-resolver/tree/v7.3.2"
},
"funding": [
{
@@ -9717,12 +9945,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-04-04T13:12:05+00:00"
+ "time": "2025-07-15T11:36:08+00:00"
},
{
"name": "symfony/polyfill-ctype",
@@ -10587,16 +10819,16 @@
},
{
"name": "symfony/routing",
- "version": "v7.3.0",
+ "version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/routing.git",
- "reference": "8e213820c5fea844ecea29203d2a308019007c15"
+ "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/routing/zipball/8e213820c5fea844ecea29203d2a308019007c15",
- "reference": "8e213820c5fea844ecea29203d2a308019007c15",
+ "url": "https://api.github.com/repos/symfony/routing/zipball/7614b8ca5fa89b9cd233e21b627bfc5774f586e4",
+ "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4",
"shasum": ""
},
"require": {
@@ -10648,7 +10880,7 @@
"url"
],
"support": {
- "source": "https://github.com/symfony/routing/tree/v7.3.0"
+ "source": "https://github.com/symfony/routing/tree/v7.3.2"
},
"funding": [
{
@@ -10659,12 +10891,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-05-24T20:43:28+00:00"
+ "time": "2025-07-15T11:36:08+00:00"
},
{
"name": "symfony/service-contracts",
@@ -10813,16 +11049,16 @@
},
{
"name": "symfony/string",
- "version": "v7.3.0",
+ "version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125"
+ "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/f3570b8c61ca887a9e2938e85cb6458515d2b125",
- "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125",
+ "url": "https://api.github.com/repos/symfony/string/zipball/42f505aff654e62ac7ac2ce21033818297ca89ca",
+ "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca",
"shasum": ""
},
"require": {
@@ -10880,7 +11116,7 @@
"utf8"
],
"support": {
- "source": "https://github.com/symfony/string/tree/v7.3.0"
+ "source": "https://github.com/symfony/string/tree/v7.3.2"
},
"funding": [
{
@@ -10891,25 +11127,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-04-20T20:19:01+00:00"
+ "time": "2025-07-10T08:47:49+00:00"
},
{
"name": "symfony/translation",
- "version": "v7.3.1",
+ "version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation.git",
- "reference": "241d5ac4910d256660238a7ecf250deba4c73063"
+ "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/translation/zipball/241d5ac4910d256660238a7ecf250deba4c73063",
- "reference": "241d5ac4910d256660238a7ecf250deba4c73063",
+ "url": "https://api.github.com/repos/symfony/translation/zipball/81b48f4daa96272efcce9c7a6c4b58e629df3c90",
+ "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90",
"shasum": ""
},
"require": {
@@ -10976,7 +11216,7 @@
"description": "Provides tools to internationalize your application",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/translation/tree/v7.3.1"
+ "source": "https://github.com/symfony/translation/tree/v7.3.2"
},
"funding": [
{
@@ -10987,12 +11227,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-06-27T19:55:54+00:00"
+ "time": "2025-07-30T17:31:46+00:00"
},
{
"name": "symfony/translation-contracts",
@@ -11148,16 +11392,16 @@
},
{
"name": "symfony/var-dumper",
- "version": "v7.3.1",
+ "version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-dumper.git",
- "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42"
+ "reference": "53205bea27450dc5c65377518b3275e126d45e75"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6e209fbe5f5a7b6043baba46fe5735a4b85d0d42",
- "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42",
+ "url": "https://api.github.com/repos/symfony/var-dumper/zipball/53205bea27450dc5c65377518b3275e126d45e75",
+ "reference": "53205bea27450dc5c65377518b3275e126d45e75",
"shasum": ""
},
"require": {
@@ -11169,7 +11413,6 @@
"symfony/console": "<6.4"
},
"require-dev": {
- "ext-iconv": "*",
"symfony/console": "^6.4|^7.0",
"symfony/http-kernel": "^6.4|^7.0",
"symfony/process": "^6.4|^7.0",
@@ -11212,7 +11455,7 @@
"dump"
],
"support": {
- "source": "https://github.com/symfony/var-dumper/tree/v7.3.1"
+ "source": "https://github.com/symfony/var-dumper/tree/v7.3.2"
},
"funding": [
{
@@ -11223,25 +11466,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-06-27T19:55:54+00:00"
+ "time": "2025-07-29T20:02:46+00:00"
},
{
"name": "symfony/yaml",
- "version": "v7.3.1",
+ "version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
- "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb"
+ "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/0c3555045a46ab3cd4cc5a69d161225195230edb",
- "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30",
+ "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30",
"shasum": ""
},
"require": {
@@ -11284,7 +11531,7 @@
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/yaml/tree/v7.3.1"
+ "source": "https://github.com/symfony/yaml/tree/v7.3.2"
},
"funding": [
{
@@ -11295,12 +11542,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-06-03T06:57:57+00:00"
+ "time": "2025-07-10T08:47:49+00:00"
},
{
"name": "tijsverkoyen/css-to-inline-styles",
@@ -12039,16 +12290,16 @@
"packages-dev": [
{
"name": "barryvdh/laravel-debugbar",
- "version": "v3.15.4",
+ "version": "v3.16.0",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-debugbar.git",
- "reference": "c0667ea91f7185f1e074402c5788195e96bf8106"
+ "reference": "f265cf5e38577d42311f1a90d619bcd3740bea23"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/c0667ea91f7185f1e074402c5788195e96bf8106",
- "reference": "c0667ea91f7185f1e074402c5788195e96bf8106",
+ "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/f265cf5e38577d42311f1a90d619bcd3740bea23",
+ "reference": "f265cf5e38577d42311f1a90d619bcd3740bea23",
"shasum": ""
},
"require": {
@@ -12056,7 +12307,7 @@
"illuminate/session": "^9|^10|^11|^12",
"illuminate/support": "^9|^10|^11|^12",
"php": "^8.1",
- "php-debugbar/php-debugbar": "~2.1.1",
+ "php-debugbar/php-debugbar": "~2.2.0",
"symfony/finder": "^6|^7"
},
"require-dev": {
@@ -12076,7 +12327,7 @@
]
},
"branch-alias": {
- "dev-master": "3.15-dev"
+ "dev-master": "3.16-dev"
}
},
"autoload": {
@@ -12108,7 +12359,7 @@
],
"support": {
"issues": "https://github.com/barryvdh/laravel-debugbar/issues",
- "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.15.4"
+ "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.16.0"
},
"funding": [
{
@@ -12120,7 +12371,7 @@
"type": "github"
}
],
- "time": "2025-04-16T06:32:06+00:00"
+ "time": "2025-07-14T11:56:43+00:00"
},
{
"name": "brianium/paratest",
@@ -12496,6 +12747,71 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
+ {
+ "name": "laravel/boost",
+ "version": "v1.1.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/boost.git",
+ "reference": "70f909465bf73dad7e791fad8b7716b3b2712076"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/boost/zipball/70f909465bf73dad7e791fad8b7716b3b2712076",
+ "reference": "70f909465bf73dad7e791fad8b7716b3b2712076",
+ "shasum": ""
+ },
+ "require": {
+ "guzzlehttp/guzzle": "^7.9",
+ "illuminate/console": "^10.0|^11.0|^12.0",
+ "illuminate/contracts": "^10.0|^11.0|^12.0",
+ "illuminate/routing": "^10.0|^11.0|^12.0",
+ "illuminate/support": "^10.0|^11.0|^12.0",
+ "laravel/mcp": "^0.1.1",
+ "laravel/prompts": "^0.1.9|^0.3",
+ "laravel/roster": "^0.2.5",
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.14",
+ "mockery/mockery": "^1.6",
+ "orchestra/testbench": "^8.22.0|^9.0|^10.0",
+ "pestphp/pest": "^2.0|^3.0",
+ "phpstan/phpstan": "^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Laravel\\Boost\\BoostServiceProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Boost\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.",
+ "homepage": "https://github.com/laravel/boost",
+ "keywords": [
+ "ai",
+ "dev",
+ "laravel"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/boost/issues",
+ "source": "https://github.com/laravel/boost"
+ },
+ "time": "2025-09-04T12:16:09+00:00"
+ },
{
"name": "laravel/dusk",
"version": "v8.3.3",
@@ -12570,6 +12886,70 @@
},
"time": "2025-06-10T13:59:27+00:00"
},
+ {
+ "name": "laravel/mcp",
+ "version": "v0.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/mcp.git",
+ "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713",
+ "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/console": "^10.0|^11.0|^12.0",
+ "illuminate/contracts": "^10.0|^11.0|^12.0",
+ "illuminate/http": "^10.0|^11.0|^12.0",
+ "illuminate/routing": "^10.0|^11.0|^12.0",
+ "illuminate/support": "^10.0|^11.0|^12.0",
+ "illuminate/validation": "^10.0|^11.0|^12.0",
+ "php": "^8.1|^8.2"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.14",
+ "orchestra/testbench": "^8.22.0|^9.0|^10.0",
+ "phpstan/phpstan": "^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "aliases": {
+ "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
+ },
+ "providers": [
+ "Laravel\\Mcp\\Server\\McpServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Mcp\\": "src/",
+ "Workbench\\App\\": "workbench/app/",
+ "Laravel\\Mcp\\Tests\\": "tests/",
+ "Laravel\\Mcp\\Server\\": "src/Server/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "The easiest way to add MCP servers to your Laravel app.",
+ "homepage": "https://github.com/laravel/mcp",
+ "keywords": [
+ "dev",
+ "laravel",
+ "mcp"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/mcp/issues",
+ "source": "https://github.com/laravel/mcp"
+ },
+ "time": "2025-08-16T09:50:43+00:00"
+ },
{
"name": "laravel/pint",
"version": "v1.24.0",
@@ -12640,17 +13020,78 @@
"time": "2025-07-10T18:09:32+00:00"
},
{
- "name": "laravel/telescope",
- "version": "v5.10.0",
+ "name": "laravel/roster",
+ "version": "v0.2.6",
"source": {
"type": "git",
- "url": "https://github.com/laravel/telescope.git",
- "reference": "fc0a8662682c0375b534033873debb780c003486"
+ "url": "https://github.com/laravel/roster.git",
+ "reference": "5615acdf860c5a5c61d04aba44f2d3312550c514"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/telescope/zipball/fc0a8662682c0375b534033873debb780c003486",
- "reference": "fc0a8662682c0375b534033873debb780c003486",
+ "url": "https://api.github.com/repos/laravel/roster/zipball/5615acdf860c5a5c61d04aba44f2d3312550c514",
+ "reference": "5615acdf860c5a5c61d04aba44f2d3312550c514",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/console": "^10.0|^11.0|^12.0",
+ "illuminate/contracts": "^10.0|^11.0|^12.0",
+ "illuminate/routing": "^10.0|^11.0|^12.0",
+ "illuminate/support": "^10.0|^11.0|^12.0",
+ "php": "^8.1|^8.2",
+ "symfony/yaml": "^6.4|^7.2"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.14",
+ "mockery/mockery": "^1.6",
+ "orchestra/testbench": "^8.22.0|^9.0|^10.0",
+ "pestphp/pest": "^2.0|^3.0",
+ "phpstan/phpstan": "^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Laravel\\Roster\\RosterServiceProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Roster\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Detect packages & approaches in use within a Laravel project",
+ "homepage": "https://github.com/laravel/roster",
+ "keywords": [
+ "dev",
+ "laravel"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/roster/issues",
+ "source": "https://github.com/laravel/roster"
+ },
+ "time": "2025-09-04T07:31:39+00:00"
+ },
+ {
+ "name": "laravel/telescope",
+ "version": "v5.10.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/telescope.git",
+ "reference": "6d249d93ab06dc147ac62ea02b4272c2e7a24b72"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/telescope/zipball/6d249d93ab06dc147ac62ea02b4272c2e7a24b72",
+ "reference": "6d249d93ab06dc147ac62ea02b4272c2e7a24b72",
"shasum": ""
},
"require": {
@@ -12704,9 +13145,9 @@
],
"support": {
"issues": "https://github.com/laravel/telescope/issues",
- "source": "https://github.com/laravel/telescope/tree/v5.10.0"
+ "source": "https://github.com/laravel/telescope/tree/v5.10.2"
},
- "time": "2025-07-07T14:47:19+00:00"
+ "time": "2025-07-24T05:26:13+00:00"
},
{
"name": "mockery/mockery",
@@ -12793,16 +13234,16 @@
},
{
"name": "myclabs/deep-copy",
- "version": "1.13.3",
+ "version": "1.13.4",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
- "reference": "faed855a7b5f4d4637717c2b3863e277116beb36"
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/faed855a7b5f4d4637717c2b3863e277116beb36",
- "reference": "faed855a7b5f4d4637717c2b3863e277116beb36",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"shasum": ""
},
"require": {
@@ -12841,7 +13282,7 @@
],
"support": {
"issues": "https://github.com/myclabs/DeepCopy/issues",
- "source": "https://github.com/myclabs/DeepCopy/tree/1.13.3"
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
},
"funding": [
{
@@ -12849,7 +13290,7 @@
"type": "tidelift"
}
],
- "time": "2025-07-05T12:25:42+00:00"
+ "time": "2025-08-01T08:46:24+00:00"
},
{
"name": "nunomaduro/collision",
@@ -13394,16 +13835,16 @@
},
{
"name": "php-debugbar/php-debugbar",
- "version": "v2.1.6",
+ "version": "v2.2.4",
"source": {
"type": "git",
"url": "https://github.com/php-debugbar/php-debugbar.git",
- "reference": "16fa68da5617220594aa5e33fa9de415f94784a0"
+ "reference": "3146d04671f51f69ffec2a4207ac3bdcf13a9f35"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/16fa68da5617220594aa5e33fa9de415f94784a0",
- "reference": "16fa68da5617220594aa5e33fa9de415f94784a0",
+ "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/3146d04671f51f69ffec2a4207ac3bdcf13a9f35",
+ "reference": "3146d04671f51f69ffec2a4207ac3bdcf13a9f35",
"shasum": ""
},
"require": {
@@ -13411,6 +13852,9 @@
"psr/log": "^1|^2|^3",
"symfony/var-dumper": "^4|^5|^6|^7"
},
+ "replace": {
+ "maximebf/debugbar": "self.version"
+ },
"require-dev": {
"dbrekelmans/bdi": "^1",
"phpunit/phpunit": "^8|^9",
@@ -13425,7 +13869,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.0-dev"
+ "dev-master": "2.1-dev"
}
},
"autoload": {
@@ -13458,9 +13902,9 @@
],
"support": {
"issues": "https://github.com/php-debugbar/php-debugbar/issues",
- "source": "https://github.com/php-debugbar/php-debugbar/tree/v2.1.6"
+ "source": "https://github.com/php-debugbar/php-debugbar/tree/v2.2.4"
},
- "time": "2025-02-21T17:47:03+00:00"
+ "time": "2025-07-22T14:01:30+00:00"
},
{
"name": "php-webdriver/webdriver",
@@ -13530,16 +13974,16 @@
},
{
"name": "phpstan/phpstan",
- "version": "2.1.18",
+ "version": "2.1.21",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan.git",
- "reference": "ee1f390b7a70cdf74a2b737e554f68afea885db7"
+ "reference": "1ccf445757458c06a04eb3f803603cb118fe5fa6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ee1f390b7a70cdf74a2b737e554f68afea885db7",
- "reference": "ee1f390b7a70cdf74a2b737e554f68afea885db7",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1ccf445757458c06a04eb3f803603cb118fe5fa6",
+ "reference": "1ccf445757458c06a04eb3f803603cb118fe5fa6",
"shasum": ""
},
"require": {
@@ -13584,7 +14028,7 @@
"type": "github"
}
],
- "time": "2025-07-17T17:22:31+00:00"
+ "time": "2025-07-28T19:35:08+00:00"
},
{
"name": "phpunit/php-code-coverage",
@@ -15436,16 +15880,16 @@
},
{
"name": "symfony/http-client",
- "version": "v7.3.1",
+ "version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
- "reference": "4403d87a2c16f33345dca93407a8714ee8c05a64"
+ "reference": "1c064a0c67749923483216b081066642751cc2c7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client/zipball/4403d87a2c16f33345dca93407a8714ee8c05a64",
- "reference": "4403d87a2c16f33345dca93407a8714ee8c05a64",
+ "url": "https://api.github.com/repos/symfony/http-client/zipball/1c064a0c67749923483216b081066642751cc2c7",
+ "reference": "1c064a0c67749923483216b081066642751cc2c7",
"shasum": ""
},
"require": {
@@ -15511,7 +15955,7 @@
"http"
],
"support": {
- "source": "https://github.com/symfony/http-client/tree/v7.3.1"
+ "source": "https://github.com/symfony/http-client/tree/v7.3.2"
},
"funding": [
{
@@ -15522,12 +15966,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-06-28T07:58:39+00:00"
+ "time": "2025-07-15T11:36:08+00:00"
},
{
"name": "symfony/http-client-contracts",
diff --git a/config/constants.php b/config/constants.php
index c7a36d311..224f2dfb5 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,8 +2,8 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.420.7',
- 'helper_version' => '1.0.9',
+ 'version' => '4.0.0-beta.429',
+ 'helper_version' => '1.0.11',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
@@ -12,6 +12,7 @@
'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'),
'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'),
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
+ 'releases_url' => 'https://cdn.coollabs.io/coolify/releases.json',
],
'urls' => [
@@ -22,7 +23,8 @@
'services' => [
// Temporary disabled until cache is implemented
// 'official' => 'https://cdn.coollabs.io/coolify/service-templates.json',
- 'official' => 'https://raw.githubusercontent.com/coollabsio/coolify/main/templates/service-templates.json',
+ 'official' => 'https://raw.githubusercontent.com/coollabsio/coolify/v4.x/templates/service-templates-latest.json',
+ 'file_name' => 'service-templates-latest.json',
],
'terminal' => [
@@ -57,9 +59,16 @@
'ssh' => [
'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true)),
'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', 3600),
+ 'mux_health_check_enabled' => env('SSH_MUX_HEALTH_CHECK_ENABLED', true),
+ 'mux_health_check_timeout' => env('SSH_MUX_HEALTH_CHECK_TIMEOUT', 5),
+ 'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes
'connection_timeout' => 10,
'server_interval' => 20,
'command_timeout' => 7200,
+ '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
+ 'retry_multiplier' => env('SSH_RETRY_MULTIPLIER', 2),
],
'invitation' => [
@@ -69,6 +78,10 @@
],
],
+ 'email_change' => [
+ 'verification_code_expiry_minutes' => 10,
+ ],
+
'sentry' => [
'sentry_dsn' => env('SENTRY_DSN'),
],
diff --git a/config/services.php b/config/services.php
index 7add50a5c..6a21cda18 100644
--- a/config/services.php
+++ b/config/services.php
@@ -65,6 +65,6 @@
'client_secret' => env('ZITADEL_CLIENT_SECRET'),
'redirect' => env('ZITADEL_REDIRECT_URI'),
'base_url' => env('ZITADEL_BASE_URL'),
- ]
+ ],
];
diff --git a/database/migrations/2025_08_07_142403_create_user_changelog_reads_table.php b/database/migrations/2025_08_07_142403_create_user_changelog_reads_table.php
new file mode 100644
index 000000000..db8a42fb7
--- /dev/null
+++ b/database/migrations/2025_08_07_142403_create_user_changelog_reads_table.php
@@ -0,0 +1,34 @@
+id();
+ $table->foreignId('user_id')->constrained()->onDelete('cascade');
+ $table->string('release_tag'); // GitHub tag_name (e.g., "v4.0.0-beta.420.6")
+ $table->timestamp('read_at');
+ $table->timestamps();
+
+ $table->unique(['user_id', 'release_tag']);
+ $table->index('user_id');
+ $table->index('release_tag');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('user_changelog_reads');
+ }
+};
diff --git a/database/migrations/2025_08_17_102422_add_disable_local_backup_to_scheduled_database_backups_table.php b/database/migrations/2025_08_17_102422_add_disable_local_backup_to_scheduled_database_backups_table.php
new file mode 100644
index 000000000..e414472df
--- /dev/null
+++ b/database/migrations/2025_08_17_102422_add_disable_local_backup_to_scheduled_database_backups_table.php
@@ -0,0 +1,28 @@
+boolean('disable_local_backup')->default(false)->after('save_s3');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('scheduled_database_backups', function (Blueprint $table) {
+ $table->dropColumn('disable_local_backup');
+ });
+ }
+};
diff --git a/database/migrations/2025_08_18_104146_add_email_change_fields_to_users_table.php b/database/migrations/2025_08_18_104146_add_email_change_fields_to_users_table.php
new file mode 100644
index 000000000..9cefe2c09
--- /dev/null
+++ b/database/migrations/2025_08_18_104146_add_email_change_fields_to_users_table.php
@@ -0,0 +1,30 @@
+string('pending_email')->nullable()->after('email');
+ $table->string('email_change_code', 6)->nullable()->after('pending_email');
+ $table->timestamp('email_change_code_expires_at')->nullable()->after('email_change_code');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn(['pending_email', 'email_change_code', 'email_change_code_expires_at']);
+ });
+ }
+};
diff --git a/database/migrations/2025_08_18_154244_change_env_sorting_default_to_false.php b/database/migrations/2025_08_18_154244_change_env_sorting_default_to_false.php
new file mode 100644
index 000000000..32ed075ba
--- /dev/null
+++ b/database/migrations/2025_08_18_154244_change_env_sorting_default_to_false.php
@@ -0,0 +1,18 @@
+boolean('is_env_sorting_enabled')->default(false)->change();
+ });
+ }
+};
diff --git a/database/migrations/2025_08_21_080234_add_git_shallow_clone_to_application_settings_table.php b/database/migrations/2025_08_21_080234_add_git_shallow_clone_to_application_settings_table.php
new file mode 100644
index 000000000..399c49c7f
--- /dev/null
+++ b/database/migrations/2025_08_21_080234_add_git_shallow_clone_to_application_settings_table.php
@@ -0,0 +1,28 @@
+boolean('is_git_shallow_clone_enabled')->default(true)->after('is_git_lfs_enabled');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('application_settings', function (Blueprint $table) {
+ $table->dropColumn('is_git_shallow_clone_enabled');
+ });
+ }
+};
diff --git a/database/migrations/2025_09_05_142446_add_pr_deployments_public_enabled_to_application_settings.php b/database/migrations/2025_09_05_142446_add_pr_deployments_public_enabled_to_application_settings.php
new file mode 100644
index 000000000..5d84ce42d
--- /dev/null
+++ b/database/migrations/2025_09_05_142446_add_pr_deployments_public_enabled_to_application_settings.php
@@ -0,0 +1,28 @@
+boolean('is_pr_deployments_public_enabled')->default(false)->after('is_preview_deployments_enabled');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('application_settings', function (Blueprint $table) {
+ $table->dropColumn('is_pr_deployments_public_enabled');
+ });
+ }
+};
diff --git a/database/migrations/2025_09_10_172952_remove_is_readonly_from_local_persistent_volumes_table.php b/database/migrations/2025_09_10_172952_remove_is_readonly_from_local_persistent_volumes_table.php
new file mode 100644
index 000000000..31398bd35
--- /dev/null
+++ b/database/migrations/2025_09_10_172952_remove_is_readonly_from_local_persistent_volumes_table.php
@@ -0,0 +1,28 @@
+dropColumn('is_readonly');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('local_persistent_volumes', function (Blueprint $table) {
+ $table->boolean('is_readonly')->default(false);
+ });
+ }
+};
diff --git a/database/migrations/2025_09_10_173300_drop_webhooks_table.php b/database/migrations/2025_09_10_173300_drop_webhooks_table.php
new file mode 100644
index 000000000..4cb1b4e70
--- /dev/null
+++ b/database/migrations/2025_09_10_173300_drop_webhooks_table.php
@@ -0,0 +1,31 @@
+id();
+ $table->enum('status', ['pending', 'success', 'failed'])->default('pending');
+ $table->string('type');
+ $table->longText('payload');
+ $table->longText('failure_reason')->nullable();
+ $table->timestamps();
+ });
+ }
+};
diff --git a/database/migrations/2025_09_10_173402_drop_kubernetes_table.php b/database/migrations/2025_09_10_173402_drop_kubernetes_table.php
new file mode 100644
index 000000000..329ed0e7e
--- /dev/null
+++ b/database/migrations/2025_09_10_173402_drop_kubernetes_table.php
@@ -0,0 +1,28 @@
+id();
+ $table->string('uuid')->unique();
+ $table->timestamps();
+ });
+ }
+};
diff --git a/database/migrations/2025_09_11_143432_remove_is_build_time_from_environment_variables_table.php b/database/migrations/2025_09_11_143432_remove_is_build_time_from_environment_variables_table.php
new file mode 100644
index 000000000..076ee8e09
--- /dev/null
+++ b/database/migrations/2025_09_11_143432_remove_is_build_time_from_environment_variables_table.php
@@ -0,0 +1,38 @@
+dropColumn('is_build_time');
+ }
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('environment_variables', function (Blueprint $table) {
+ // Re-add the is_build_time column
+ if (! Schema::hasColumn('environment_variables', 'is_build_time')) {
+ $table->boolean('is_build_time')->default(false)->after('value');
+ }
+ });
+ }
+};
diff --git a/database/migrations/2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table.php b/database/migrations/2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table.php
new file mode 100644
index 000000000..d95f351d5
--- /dev/null
+++ b/database/migrations/2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table.php
@@ -0,0 +1,28 @@
+boolean('is_buildtime_only')->default(false)->after('is_preview');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('environment_variables', function (Blueprint $table) {
+ $table->dropColumn('is_buildtime_only');
+ });
+ }
+};
diff --git a/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php b/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php
new file mode 100644
index 000000000..b78f391fc
--- /dev/null
+++ b/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php
@@ -0,0 +1,28 @@
+boolean('use_build_secrets')->default(false)->after('is_build_server_enabled');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('application_settings', function (Blueprint $table) {
+ $table->dropColumn('use_build_secrets');
+ });
+ }
+};
diff --git a/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php b/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php
new file mode 100644
index 000000000..6fd4bfed6
--- /dev/null
+++ b/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php
@@ -0,0 +1,67 @@
+boolean('is_runtime')->default(true)->after('is_buildtime_only');
+ $table->boolean('is_buildtime')->default(true)->after('is_runtime');
+ });
+
+ // Migrate existing data from is_buildtime_only to new fields
+ DB::table('environment_variables')
+ ->where('is_buildtime_only', true)
+ ->update([
+ 'is_runtime' => false,
+ 'is_buildtime' => true,
+ ]);
+
+ DB::table('environment_variables')
+ ->where('is_buildtime_only', false)
+ ->update([
+ 'is_runtime' => true,
+ 'is_buildtime' => true,
+ ]);
+
+ // Remove the old is_buildtime_only column
+ Schema::table('environment_variables', function (Blueprint $table) {
+ $table->dropColumn('is_buildtime_only');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('environment_variables', function (Blueprint $table) {
+ // Re-add the is_buildtime_only column
+ $table->boolean('is_buildtime_only')->default(false)->after('is_preview');
+ });
+
+ // Restore data to is_buildtime_only based on new fields
+ DB::table('environment_variables')
+ ->where('is_runtime', false)
+ ->where('is_buildtime', true)
+ ->update(['is_buildtime_only' => true]);
+
+ DB::table('environment_variables')
+ ->where('is_runtime', true)
+ ->update(['is_buildtime_only' => false]);
+
+ // Remove new columns
+ Schema::table('environment_variables', function (Blueprint $table) {
+ $table->dropColumn(['is_runtime', 'is_buildtime']);
+ });
+ }
+};
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 3fadd914c..e8402b7af 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -59,7 +59,7 @@ services:
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"]
vite:
- image: node:20-alpine
+ image: node:24-alpine
pull_policy: always
working_dir: /var/www/html
environment:
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 57f062202..b90f126a2 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -61,7 +61,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.9'
+ image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"
diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml
index 519309e39..cd4a307aa 100644
--- a/docker-compose.windows.yml
+++ b/docker-compose.windows.yml
@@ -103,7 +103,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: 'ghcr.io/coollabsio/coolify-realtime:1.0.6'
+ image: 'ghcr.io/coollabsio/coolify-realtime:1.0.10'
pull_policy: always
container_name: coolify-realtime
restart: always
diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile
index 8c7073519..212703798 100644
--- a/docker/coolify-helper/Dockerfile
+++ b/docker/coolify-helper/Dockerfile
@@ -10,9 +10,10 @@ ARG DOCKER_BUILDX_VERSION=0.25.0
# https://github.com/buildpacks/pack/releases
ARG PACK_VERSION=0.38.2
# https://github.com/railwayapp/nixpacks/releases
-ARG NIXPACKS_VERSION=1.39.0
+ARG NIXPACKS_VERSION=1.40.0
# https://github.com/minio/mc/releases
-ARG MINIO_VERSION=RELEASE.2025-05-21T01-59-54Z
+ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z
+
FROM minio/mc:${MINIO_VERSION} AS minio-client
diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json
index 49907cbd4..c445c972c 100644
--- a/docker/coolify-realtime/package-lock.json
+++ b/docker/coolify-realtime/package-lock.json
@@ -7,7 +7,7 @@
"dependencies": {
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
- "axios": "1.8.4",
+ "axios": "1.12.0",
"cookie": "1.0.2",
"dotenv": "16.5.0",
"node-pty": "1.0.0",
@@ -36,13 +36,13 @@
"license": "MIT"
},
"node_modules/axios": {
- "version": "1.8.4",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
- "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
+ "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
- "form-data": "^4.0.0",
+ "form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
diff --git a/docker/coolify-realtime/package.json b/docker/coolify-realtime/package.json
index 7851d7f4d..aec3dbe3d 100644
--- a/docker/coolify-realtime/package.json
+++ b/docker/coolify-realtime/package.json
@@ -5,7 +5,7 @@
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"cookie": "1.0.2",
- "axios": "1.8.4",
+ "axios": "1.12.0",
"dotenv": "16.5.0",
"node-pty": "1.0.0",
"ws": "8.18.1"
diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile
index a2a4b5fa3..6c9628a81 100644
--- a/docker/production/Dockerfile
+++ b/docker/production/Dockerfile
@@ -120,6 +120,7 @@ COPY --chown=www-data:www-data templates ./templates
COPY --chown=www-data:www-data resources/views ./resources/views
COPY --chown=www-data:www-data artisan artisan
COPY --chown=www-data:www-data openapi.yaml ./openapi.yaml
+COPY --chown=www-data:www-data changelogs/ ./changelogs/
RUN composer dump-autoload
diff --git a/hooks/pre-commit b/hooks/pre-commit
index 029f67917..fc96e9766 100644
--- a/hooks/pre-commit
+++ b/hooks/pre-commit
@@ -4,6 +4,19 @@ if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then
exec غير مستحسن، لأن خوادم Let's Encrypt مع هذا النطاق العام محدودة المعدل (ستفشل عملية التحقق من شهادة SSL).
استخدم نطاقك الخاص بدلاً من ذلك."
-}
+}
\ No newline at end of file
diff --git a/lang/az.json b/lang/az.json
index 92f56ddbc..85cee7589 100644
--- a/lang/az.json
+++ b/lang/az.json
@@ -11,7 +11,8 @@
"auth.login.infomaniak": "Infomaniak ilə daxil ol",
"auth.already_registered": "Qeytiyatınız var?",
"auth.confirm_password": "Şifrəni təsdiqləyin",
- "auth.forgot_password": "Şifrəmi unutdum",
+ "auth.forgot_password_link": "Şifrəmi unutdum?",
+ "auth.forgot_password_heading": "Şifrəni bərpa et",
"auth.forgot_password_send_email": "Şifrəni sıfırlamaq üçün e-poçt göndər",
"auth.register_now": "Qeydiyyat",
"auth.logout": "Çıxış",
@@ -39,4 +40,4 @@
"resource.delete_configurations": "Serverdən bütün konfiqurasiya faylları tamamilə silinəcək.",
"database.delete_backups_locally": "Bütün ehtiyat nüsxələr lokal yaddaşdan tamamilə silinəcək.",
"warning.sslipdomain": "Konfiqurasiya yadda saxlanıldı, lakin sslip domeni ilə https TÖVSİYƏ EDİLMİR, çünki Let's Encrypt serverləri bu ümumi domenlə məhdudlaşdırılır (SSL sertifikatının təsdiqlənməsi uğursuz olacaq).
Əvəzində öz domeninizdən istifadə edin."
-}
+}
\ No newline at end of file
diff --git a/lang/cs.json b/lang/cs.json
index 00455aa81..9e5d2c44e 100644
--- a/lang/cs.json
+++ b/lang/cs.json
@@ -10,7 +10,8 @@
"auth.login.infomaniak": "Přihlásit se pomocí Infomaniak",
"auth.already_registered": "Již jste registrováni?",
"auth.confirm_password": "Potvrďte heslo",
- "auth.forgot_password": "Zapomněli jste heslo",
+ "auth.forgot_password_link": "Zapomněli jste heslo?",
+ "auth.forgot_password_heading": "Obnovení hesla",
"auth.forgot_password_send_email": "Poslat e-mail pro resetování hesla",
"auth.register_now": "Registrovat se",
"auth.logout": "Odhlásit se",
@@ -30,4 +31,4 @@
"input.recovery_code": "Obnovovací kód",
"button.save": "Uložit",
"repository.url": "Příklady
Pro veřejné repozitáře, použijte https://....
Pro soukromé repozitáře, použijte git@....
https://github.com/coollabsio/coolify-examples main branch bude zvolena
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify branch bude vybrána.
https://gitea.com/sedlav/expressjs.git main branch vybrána.
https://gitlab.com/andrasbacsai/nodejs-example.git main branch bude vybrána."
-}
+}
\ No newline at end of file
diff --git a/lang/de.json b/lang/de.json
index f56b21710..fd587de22 100644
--- a/lang/de.json
+++ b/lang/de.json
@@ -11,7 +11,8 @@
"auth.login.zitadel": "Mit Zitadel anmelden",
"auth.already_registered": "Bereits registriert?",
"auth.confirm_password": "Passwort bestätigen",
- "auth.forgot_password": "Passwort vergessen",
+ "auth.forgot_password_link": "Passwort vergessen?",
+ "auth.forgot_password_heading": "Passwort-Wiederherstellung",
"auth.forgot_password_send_email": "Passwort zurücksetzen E-Mail senden",
"auth.register_now": "Registrieren",
"auth.logout": "Abmelden",
@@ -31,4 +32,4 @@
"input.recovery_code": "Wiederherstellungscode",
"button.save": "Speichern",
"repository.url": "Beispiele
Für öffentliche Repositories benutze https://....
Für private Repositories benutze git@....
https://github.com/coollabsio/coolify-examples main Branch wird ausgewählt
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify Branch wird ausgewählt.
https://gitea.com/sedlav/expressjs.git main Branch wird ausgewählt.
https://gitlab.com/andrasbacsai/nodejs-example.git main Branch wird ausgewählt."
-}
+}
\ No newline at end of file
diff --git a/lang/en.json b/lang/en.json
index 4a398a9f9..af7f2145d 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -12,7 +12,8 @@
"auth.login.zitadel": "Login with Zitadel",
"auth.already_registered": "Already registered?",
"auth.confirm_password": "Confirm password",
- "auth.forgot_password": "Forgot password",
+ "auth.forgot_password_link": "Forgot password?",
+ "auth.forgot_password_heading": "Password recovery",
"auth.forgot_password_send_email": "Send password reset email",
"auth.register_now": "Register",
"auth.logout": "Logout",
@@ -40,4 +41,4 @@
"resource.delete_configurations": "Permanently delete all configuration files from the server.",
"database.delete_backups_locally": "All backups will be permanently deleted from local storage.",
"warning.sslipdomain": "Your configuration is saved, but sslip domain with https is NOT recommended, because Let's Encrypt servers with this public domain are rate limited (SSL certificate validation will fail).
Use your own domain instead."
-}
+}
\ No newline at end of file
diff --git a/lang/es.json b/lang/es.json
index 73363a9bf..f56387f05 100644
--- a/lang/es.json
+++ b/lang/es.json
@@ -10,7 +10,8 @@
"auth.login.infomaniak": "Acceder con Infomaniak",
"auth.already_registered": "¿Ya estás registrado?",
"auth.confirm_password": "Confirmar contraseña",
- "auth.forgot_password": "¿Olvidaste tu contraseña?",
+ "auth.forgot_password_link": "¿Olvidaste tu contraseña?",
+ "auth.forgot_password_heading": "Recuperación de contraseña",
"auth.forgot_password_send_email": "Enviar correo de recuperación de contraseña",
"auth.register_now": "Registrar",
"auth.logout": "Cerrar sesión",
@@ -30,4 +31,4 @@
"input.recovery_code": "Código de recuperación",
"button.save": "Guardar",
"repository.url": "Examples
Para repositorios públicos, usar https://....
Para repositorios privados, usar git@....
https://github.com/coollabsio/coolify-examples main la rama 'main' será seleccionada.
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify la rama 'nodejs-fastify' será seleccionada.
https://gitea.com/sedlav/expressjs.git main la rama 'main' será seleccionada.
https://gitlab.com/andrasbacsai/nodejs-example.git main la rama 'main' será seleccionada."
-}
+}
\ No newline at end of file
diff --git a/lang/fa.json b/lang/fa.json
index d68049e77..ae22ee946 100644
--- a/lang/fa.json
+++ b/lang/fa.json
@@ -10,7 +10,8 @@
"auth.login.infomaniak": "ورود با Infomaniak",
"auth.already_registered": "قبلاً ثبت نام کردهاید؟",
"auth.confirm_password": "تایید رمز عبور",
- "auth.forgot_password": "فراموشی رمز عبور",
+ "auth.forgot_password_link": "رمز عبور را فراموش کردهاید؟",
+ "auth.forgot_password_heading": "بازیابی رمز عبور",
"auth.forgot_password_send_email": "ارسال ایمیل بازیابی رمز عبور",
"auth.register_now": "ثبت نام",
"auth.logout": "خروج",
@@ -30,4 +31,4 @@
"input.recovery_code": "کد بازیابی",
"button.save": "ذخیره",
"repository.url": "مثالها
برای مخازن عمومی، از https://... استفاده کنید.
برای مخازن خصوصی، از git@... استفاده کنید.
شاخه main انتخاب خواهد شد.
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify شاخه nodejs-fastify انتخاب خواهد شد.
https://gitea.com/sedlav/expressjs.git شاخه main انتخاب خواهد شد.
https://gitlab.com/andrasbacsai/nodejs-example.git شاخه main انتخاب خواهد شد."
-}
+}
\ No newline at end of file
diff --git a/lang/fr.json b/lang/fr.json
index 2516d0f69..d98a1ebc8 100644
--- a/lang/fr.json
+++ b/lang/fr.json
@@ -11,7 +11,8 @@
"auth.login.infomaniak": "Connexion avec Infomaniak",
"auth.already_registered": "Déjà enregistré ?",
"auth.confirm_password": "Confirmer le mot de passe",
- "auth.forgot_password": "Mot de passe oublié",
+ "auth.forgot_password_link": "Mot de passe oublié ?",
+ "auth.forgot_password_heading": "Récupération du mot de passe",
"auth.forgot_password_send_email": "Envoyer l'email de réinitialisation de mot de passe",
"auth.register_now": "S'enregistrer",
"auth.logout": "Déconnexion",
@@ -39,4 +40,4 @@
"resource.delete_configurations": "Supprimer définitivement tous les fichiers de configuration du serveur.",
"database.delete_backups_locally": "Toutes les sauvegardes seront définitivement supprimées du stockage local.",
"warning.sslipdomain": "Votre configuration est enregistrée, mais l'utilisation du domaine sslip avec https N'EST PAS recommandée, car les serveurs Let's Encrypt avec ce domaine public sont limités en taux (la validation du certificat SSL échouera).
Utilisez plutôt votre propre domaine."
-}
+}
\ No newline at end of file
diff --git a/lang/id.json b/lang/id.json
index b0e38197a..d85176cda 100644
--- a/lang/id.json
+++ b/lang/id.json
@@ -11,7 +11,8 @@
"auth.login.infomaniak": "Masuk dengan Infomaniak",
"auth.already_registered": "Sudah terdaftar?",
"auth.confirm_password": "Konfirmasi kata sandi",
- "auth.forgot_password": "Lupa kata sandi",
+ "auth.forgot_password_link": "Lupa kata sandi?",
+ "auth.forgot_password_heading": "Pemulihan Kata Sandi",
"auth.forgot_password_send_email": "Kirim email reset kata sandi",
"auth.register_now": "Daftar",
"auth.logout": "Keluar",
@@ -39,4 +40,4 @@
"resource.delete_configurations": "Hapus permanen semua file konfigurasi dari server.",
"database.delete_backups_locally": "Semua backup akan dihapus permanen dari penyimpanan lokal.",
"warning.sslipdomain": "Konfigurasi Anda disimpan, tetapi domain sslip dengan https TIDAK direkomendasikan, karena server Let's Encrypt dengan domain publik ini dibatasi (validasi sertifikat SSL akan gagal).
Gunakan domain Anda sendiri sebagai gantinya."
-}
+}
\ No newline at end of file
diff --git a/lang/it.json b/lang/it.json
index c0edc314b..e4c1a9c05 100644
--- a/lang/it.json
+++ b/lang/it.json
@@ -11,7 +11,8 @@
"auth.login.infomaniak": "Accedi con Infomaniak",
"auth.already_registered": "Già registrato?",
"auth.confirm_password": "Conferma password",
- "auth.forgot_password": "Password dimenticata",
+ "auth.forgot_password_link": "Hai dimenticato la password?",
+ "auth.forgot_password_heading": "Recupero password",
"auth.forgot_password_send_email": "Invia email per reimpostare la password",
"auth.register_now": "Registrati",
"auth.logout": "Esci",
@@ -39,4 +40,4 @@
"resource.delete_configurations": "Elimina definitivamente tutti i file di configurazione dal server.",
"database.delete_backups_locally": "Tutti i backup verranno eliminati definitivamente dall'archiviazione locale.",
"warning.sslipdomain": "La tua configurazione è stata salvata, ma il dominio sslip con https NON è raccomandato, poiché i server di Let's Encrypt con questo dominio pubblico hanno limitazioni di frequenza (la convalida del certificato SSL fallirà).
Utilizza invece il tuo dominio personale."
-}
+}
\ No newline at end of file
diff --git a/lang/ja.json b/lang/ja.json
index 87d87d99b..05987e7ce 100644
--- a/lang/ja.json
+++ b/lang/ja.json
@@ -10,7 +10,8 @@
"auth.login.infomaniak": "Infomaniakでログイン",
"auth.already_registered": "すでに登録済みですか?",
"auth.confirm_password": "パスワードを確認",
- "auth.forgot_password": "パスワードを忘れた",
+ "auth.forgot_password_link": "パスワードをお忘れですか?",
+ "auth.forgot_password_heading": "パスワードの再設定",
"auth.forgot_password_send_email": "パスワードリセットメールを送信",
"auth.register_now": "今すぐ登録",
"auth.logout": "ログアウト",
@@ -30,4 +31,4 @@
"input.recovery_code": "リカバリーコード",
"button.save": "保存",
"repository.url": "例
公開リポジトリの場合はhttps://...を使用してください。
プライベートリポジトリの場合はgit@...を使用してください。
https://github.com/coollabsio/coolify-examples mainブランチが選択されます
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastifyブランチが選択されます。
https://gitea.com/sedlav/expressjs.git mainブランチが選択されます。
https://gitlab.com/andrasbacsai/nodejs-example.git mainブランチが選択されます。"
-}
+}
\ No newline at end of file
diff --git a/lang/no.json b/lang/no.json
index a84f6aa6c..967bdf606 100644
--- a/lang/no.json
+++ b/lang/no.json
@@ -11,7 +11,8 @@
"auth.login.infomaniak": "Logg inn med Infomaniak",
"auth.already_registered": "Allerede registrert?",
"auth.confirm_password": "Bekreft passord",
- "auth.forgot_password": "Glemt passord",
+ "auth.forgot_password_link": "Glemt passord?",
+ "auth.forgot_password_heading": "Gjenoppretting av passord",
"auth.forgot_password_send_email": "Send e-post for tilbakestilling av passord",
"auth.register_now": "Registrer deg",
"auth.logout": "Logg ut",
@@ -39,4 +40,4 @@
"resource.delete_configurations": "Slett alle konfigurasjonsfiler fra serveren permanent.",
"database.delete_backups_locally": "Alle sikkerhetskopier vil bli slettet permanent fra lokal lagring.",
"warning.sslipdomain": "Konfigurasjonen din er lagret, men sslip-domene med https er IKKE anbefalt, fordi Let's Encrypt-servere med dette offentlige domenet er hastighetsbegrenset (SSL-sertifikatvalidering vil mislykkes).
Bruk ditt eget domene i stedet."
-}
+}
\ No newline at end of file
diff --git a/lang/pl.json b/lang/pl.json
new file mode 100644
index 000000000..bcd8e2393
--- /dev/null
+++ b/lang/pl.json
@@ -0,0 +1,44 @@
+{
+ "auth.login": "Zaloguj",
+ "auth.login.authentik": "Zaloguj się przez Authentik",
+ "auth.login.azure": "Zaloguj się przez Microsoft",
+ "auth.login.bitbucket": "Zaloguj się przez Bitbucket",
+ "auth.login.clerk": "Zaloguj się przez Clerk",
+ "auth.login.discord": "Zaloguj się przez Discord",
+ "auth.login.github": "Zaloguj się przez GitHub",
+ "auth.login.gitlab": "Zaloguj się przez Gitlab",
+ "auth.login.google": "Zaloguj się przez Google",
+ "auth.login.infomaniak": "Zaloguj się przez Infomaniak",
+ "auth.login.zitadel": "Zaloguj się przez Zitadel",
+ "auth.already_registered": "Już zarejestrowany?",
+ "auth.confirm_password": "Potwierdź hasło",
+ "auth.forgot_password_link": "Zapomniałeś hasło?",
+ "auth.forgot_password_heading": "Odzyskiwanie hasła",
+ "auth.forgot_password_send_email": "Wyślij email resetujący hasło",
+ "auth.register_now": "Zarejestruj",
+ "auth.logout": "Wyloguj",
+ "auth.register": "Zarejestruj",
+ "auth.registration_disabled": "Rejestracja jest wyłączona. Skontaktuj się z administratorem.",
+ "auth.reset_password": "Zresetuj hasło",
+ "auth.failed": "Podane dane nie zgadzają się z naszymi rekordami.",
+ "auth.failed.callback": "Nie udało się przeprocesować callbacku od dostawcy logowania.",
+ "auth.failed.password": "Podane hasło jest nieprawidłowe.",
+ "auth.failed.email": "Nie znaleziono użytkownika z takim adresem email.",
+ "auth.throttle": "Zbyt wiele prób logowania. Spróbuj ponownie za :seconds sekund.",
+ "input.name": "Nazwa",
+ "input.email": "Email",
+ "input.password": "Hasło",
+ "input.password.again": "Hasło ponownie",
+ "input.code": "Jednorazowy kod",
+ "input.recovery_code": "Kod odzyskiwania",
+ "button.save": "Zapisz",
+ "repository.url": "Przykłady
Dla publicznych repozytoriów użyj https://....
Dla prywatnych repozytoriów, użyj git@....
https://github.com/coollabsio/coolify-examples - zostanie wybrany branch main
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify - zostanie wybrany branch nodejs-fastify
https://gitea.com/sedlav/expressjs.git - zostanie wybrany branch main
https://gitlab.com/andrasbacsai/nodejs-example.git - zostanie wybrany branch main",
+ "service.stop": "Ten serwis zostanie zatrzymany.",
+ "resource.docker_cleanup": "Uruchom Docker Cleanup (usunie nieużywane obrazy i cache buildera).",
+ "resource.non_persistent": "Wszystkie nietrwałe dane zostaną usunięte.",
+ "resource.delete_volumes": "Trwale usuń wszystkie wolumeny powiązane z tym zasobem.",
+ "resource.delete_connected_networks": "Trwale usuń wszystkie niepredefiniowane sieci powiązane z tym zasobem.",
+ "resource.delete_configurations": "Trwale usuń wszystkie pliki konfiguracyjne z serwera.",
+ "database.delete_backups_locally": "Wszystkie backupy zostaną trwale usunięte z lokalnej pamięci.",
+ "warning.sslipdomain": "Twoja konfiguracja została zapisana, lecz domena sslip z https jest NIEZALECANA, ponieważ serwery Let's Encrypt z tą publiczną domeną są pod rate limitem (walidacja certyfikatu SSL certificate się nie powiedzie).
Lepiej użyj własnej domeny."
+}
\ No newline at end of file
diff --git a/lang/pt-br.json b/lang/pt-br.json
index c3a102995..f3ebb6c69 100644
--- a/lang/pt-br.json
+++ b/lang/pt-br.json
@@ -11,7 +11,8 @@
"auth.login.infomaniak": "Entrar com Infomaniak",
"auth.already_registered": "Já tem uma conta?",
"auth.confirm_password": "Confirmar senha",
- "auth.forgot_password": "Esqueceu a senha",
+ "auth.forgot_password_link": "Esqueceu a senha?",
+ "auth.forgot_password_heading": "Recuperação de senha",
"auth.forgot_password_send_email": "Enviar e-mail para redefinir senha",
"auth.register_now": "Cadastre-se",
"auth.logout": "Sair",
@@ -39,4 +40,4 @@
"resource.delete_configurations": "Excluir permanentemente todos os arquivos de configuração do servidor.",
"database.delete_backups_locally": "Todos os backups serão excluídos permanentemente do armazenamento local.",
"warning.sslipdomain": "Sua configuração foi salva, mas o domínio sslip com https NÃO é recomendado, porque os servidores do Let's Encrypt com este domínio público têm limitação de taxa (a validação do certificado SSL falhará).
Use seu próprio domínio em vez disso."
-}
+}
\ No newline at end of file
diff --git a/lang/pt.json b/lang/pt.json
index 80ff8c146..08ad19df3 100644
--- a/lang/pt.json
+++ b/lang/pt.json
@@ -10,7 +10,8 @@
"auth.login.infomaniak": "Entrar com Infomaniak",
"auth.already_registered": "Já tem uma conta?",
"auth.confirm_password": "Confirmar senha",
- "auth.forgot_password": "Esqueceu a senha?",
+ "auth.forgot_password_link": "Esqueceu a senha?",
+ "auth.forgot_password_heading": "Recuperação de senha",
"auth.forgot_password_send_email": "Enviar e-mail de redefinição de senha",
"auth.register_now": "Cadastrar-se",
"auth.logout": "Sair",
@@ -30,4 +31,4 @@
"input.recovery_code": "Código de recuperação",
"button.save": "Salvar",
"repository.url": "Exemplos
Para repositórios públicos, use https://....
Para repositórios privados, use git@....
https://github.com/coollabsio/coolify-examples a branch main será selecionada
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify a branch nodejs-fastify será selecionada.
https://gitea.com/sedlav/expressjs.git a branch main será selecionada.
https://gitlab.com/andrasbacsai/nodejs-example.git a branch main será selecionada."
-}
+}
\ No newline at end of file
diff --git a/lang/ro.json b/lang/ro.json
index 5588ea6f4..18028d087 100644
--- a/lang/ro.json
+++ b/lang/ro.json
@@ -10,7 +10,8 @@
"auth.login.infomaniak": "Autentificare prin Infomaniak",
"auth.already_registered": "Sunteți deja înregistrat?",
"auth.confirm_password": "Confirmați parola",
- "auth.forgot_password": "Ați uitat parola",
+ "auth.forgot_password_link": "Ați uitat parola?",
+ "auth.forgot_password_heading": "Recuperare parolă",
"auth.forgot_password_send_email": "Trimiteți e-mail-ul pentru resetarea parolei",
"auth.register_now": "Înregistrare",
"auth.logout": "Deconectare",
@@ -37,4 +38,4 @@
"resource.delete_connected_networks": "Ștergeți definitiv toate rețelele non-predefinite asociate cu această resursă.",
"resource.delete_configurations": "Ștergeți definitiv toate fișierele de configurare de pe server.",
"database.delete_backups_locally": "Toate copiile de rezervă vor fi șterse definitiv din stocarea locală."
-}
+}
\ No newline at end of file
diff --git a/lang/tr.json b/lang/tr.json
index 74f693dc9..e3f34aa14 100644
--- a/lang/tr.json
+++ b/lang/tr.json
@@ -10,7 +10,8 @@
"auth.login.infomaniak": "Infomaniak ile Giriş Yap",
"auth.already_registered": "Zaten kayıtlı mısınız?",
"auth.confirm_password": "Şifreyi Onayla",
- "auth.forgot_password": "Şifremi Unuttum",
+ "auth.forgot_password_link": "Şifrenizi mi unuttunuz?",
+ "auth.forgot_password_heading": "Şifre Kurtarma",
"auth.forgot_password_send_email": "Şifre sıfırlama e-postası gönder",
"auth.register_now": "Kayıt Ol",
"auth.logout": "Çıkış Yap",
@@ -38,4 +39,4 @@
"resource.delete_configurations": "Sunucudaki tüm yapılandırma dosyaları kalıcı olarak silinecek.",
"database.delete_backups_locally": "Tüm yedekler yerel depolamadan kalıcı olarak silinecek.",
"warning.sslipdomain": "Yapılandırmanız kaydedildi, ancak sslip domain ile https ÖNERİLMEZ, çünkü Let's Encrypt sunucuları bu genel domain ile sınırlandırılmıştır (SSL sertifikası doğrulaması başarısız olur).
Bunun yerine kendi domaininizi kullanın."
-}
+}
\ No newline at end of file
diff --git a/lang/vi.json b/lang/vi.json
index 46edac599..76e380477 100644
--- a/lang/vi.json
+++ b/lang/vi.json
@@ -10,7 +10,8 @@
"auth.login.infomaniak": "Đăng Nhập Bằng Infomaniak",
"auth.already_registered": "Đã đăng ký?",
"auth.confirm_password": "Nhập lại mật khẩu",
- "auth.forgot_password": "Quên mật khẩu",
+ "auth.forgot_password_link": "Quên mật khẩu?",
+ "auth.forgot_password_heading": "Khôi phục mật khẩu",
"auth.forgot_password_send_email": "Gửi email đặt lại mật khẩu",
"auth.register_now": "Đăng ký ngay",
"auth.logout": "Đăng xuất",
@@ -30,4 +31,4 @@
"input.recovery_code": "Mã khôi phục",
"button.save": "Lưu",
"repository.url": "Ví dụ
Với repo công khai, sử dụng https://....
Với repo riêng tư, sử dụng git@....
https://github.com/coollabsio/coolify-examples nhánh chính sẽ được chọn
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nhánh nodejs-fastify sẽ được chọn.
https://gitea.com/sedlav/expressjs.git nhánh chính sẽ được chọn.
https://gitlab.com/andrasbacsai/nodejs-example.git nhánh chính sẽ được chọn."
-}
+}
\ No newline at end of file
diff --git a/lang/zh-cn.json b/lang/zh-cn.json
index d46c71e07..530621ee1 100644
--- a/lang/zh-cn.json
+++ b/lang/zh-cn.json
@@ -10,7 +10,8 @@
"auth.login.infomaniak": "使用 Infomaniak 登录",
"auth.already_registered": "已经注册?",
"auth.confirm_password": "确认密码",
- "auth.forgot_password": "忘记密码",
+ "auth.forgot_password_link": "忘记密码?",
+ "auth.forgot_password_heading": "密码找回",
"auth.forgot_password_send_email": "发送密码重置邮件",
"auth.register_now": "注册",
"auth.logout": "退出登录",
@@ -30,4 +31,4 @@
"input.recovery_code": "恢复码",
"button.save": "保存",
"repository.url": "示例
对于公共代码仓库,请使用 https://...。
对于私有代码仓库,请使用 git@...。
https://github.com/coollabsio/coolify-examples main 分支将被选择
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify 分支将被选择。
https://gitea.com/sedlav/expressjs.git main 分支将被选择。
https://gitlab.com/andrasbacsai/nodejs-example.git main 分支将被选择"
-}
+}
\ No newline at end of file
diff --git a/lang/zh-tw.json b/lang/zh-tw.json
index c0784c7b7..aa078104b 100644
--- a/lang/zh-tw.json
+++ b/lang/zh-tw.json
@@ -10,7 +10,8 @@
"auth.login.infomaniak": "使用 Infomaniak 登入",
"auth.already_registered": "已經註冊?",
"auth.confirm_password": "確認密碼",
- "auth.forgot_password": "忘記密碼",
+ "auth.forgot_password_link": "忘記密碼?",
+ "auth.forgot_password_heading": "密碼找回",
"auth.forgot_password_send_email": "發送重設密碼電郵",
"auth.register_now": "註冊",
"auth.logout": "登出",
@@ -30,4 +31,4 @@
"input.recovery_code": "恢復碼",
"button.save": "儲存",
"repository.url": "例子
對於公共代碼倉庫,請使用 https://...。
對於私有代碼倉庫,請使用 git@...。
https://github.com/coollabsio/coolify-examples main 分支將被選擇
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify 分支將被選擇。
https://gitea.com/sedlav/expressjs.git main 分支將被選擇。
https://gitlab.com/andrasbacsai/nodejs-example.git main 分支將被選擇。"
-}
+}
\ No newline at end of file
diff --git a/openapi.json b/openapi.json
index 791828aed..2b0a81c6e 100644
--- a/openapi.json
+++ b/openapi.json
@@ -357,6 +357,10 @@
"connect_to_docker_network": {
"type": "boolean",
"description": "The flag to connect the service to the predefined Docker network."
+ },
+ "force_domain_override": {
+ "type": "boolean",
+ "description": "Force domain usage even if conflicts are detected. Default is false."
}
},
"type": "object"
@@ -385,6 +389,60 @@
},
"400": {
"$ref": "#\/components\/responses\/400"
+ },
+ "409": {
+ "description": "Domain conflicts detected.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "Domain conflicts detected. Use force_domain_override=true to proceed."
+ },
+ "warning": {
+ "type": "string",
+ "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior."
+ },
+ "conflicts": {
+ "type": "array",
+ "items": {
+ "properties": {
+ "domain": {
+ "type": "string",
+ "example": "example.com"
+ },
+ "resource_name": {
+ "type": "string",
+ "example": "My Application"
+ },
+ "resource_uuid": {
+ "type": "string",
+ "nullable": true,
+ "example": "abc123-def456"
+ },
+ "resource_type": {
+ "type": "string",
+ "enum": [
+ "application",
+ "service",
+ "instance"
+ ],
+ "example": "application"
+ },
+ "message": {
+ "type": "string",
+ "example": "Domain example.com is already in use by application 'My Application'"
+ }
+ },
+ "type": "object"
+ }
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
}
},
"security": [
@@ -709,6 +767,10 @@
"connect_to_docker_network": {
"type": "boolean",
"description": "The flag to connect the service to the predefined Docker network."
+ },
+ "force_domain_override": {
+ "type": "boolean",
+ "description": "Force domain usage even if conflicts are detected. Default is false."
}
},
"type": "object"
@@ -737,6 +799,60 @@
},
"400": {
"$ref": "#\/components\/responses\/400"
+ },
+ "409": {
+ "description": "Domain conflicts detected.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "Domain conflicts detected. Use force_domain_override=true to proceed."
+ },
+ "warning": {
+ "type": "string",
+ "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior."
+ },
+ "conflicts": {
+ "type": "array",
+ "items": {
+ "properties": {
+ "domain": {
+ "type": "string",
+ "example": "example.com"
+ },
+ "resource_name": {
+ "type": "string",
+ "example": "My Application"
+ },
+ "resource_uuid": {
+ "type": "string",
+ "nullable": true,
+ "example": "abc123-def456"
+ },
+ "resource_type": {
+ "type": "string",
+ "enum": [
+ "application",
+ "service",
+ "instance"
+ ],
+ "example": "application"
+ },
+ "message": {
+ "type": "string",
+ "example": "Domain example.com is already in use by application 'My Application'"
+ }
+ },
+ "type": "object"
+ }
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
}
},
"security": [
@@ -1061,6 +1177,10 @@
"connect_to_docker_network": {
"type": "boolean",
"description": "The flag to connect the service to the predefined Docker network."
+ },
+ "force_domain_override": {
+ "type": "boolean",
+ "description": "Force domain usage even if conflicts are detected. Default is false."
}
},
"type": "object"
@@ -1089,6 +1209,60 @@
},
"400": {
"$ref": "#\/components\/responses\/400"
+ },
+ "409": {
+ "description": "Domain conflicts detected.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "Domain conflicts detected. Use force_domain_override=true to proceed."
+ },
+ "warning": {
+ "type": "string",
+ "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior."
+ },
+ "conflicts": {
+ "type": "array",
+ "items": {
+ "properties": {
+ "domain": {
+ "type": "string",
+ "example": "example.com"
+ },
+ "resource_name": {
+ "type": "string",
+ "example": "My Application"
+ },
+ "resource_uuid": {
+ "type": "string",
+ "nullable": true,
+ "example": "abc123-def456"
+ },
+ "resource_type": {
+ "type": "string",
+ "enum": [
+ "application",
+ "service",
+ "instance"
+ ],
+ "example": "application"
+ },
+ "message": {
+ "type": "string",
+ "example": "Domain example.com is already in use by application 'My Application'"
+ }
+ },
+ "type": "object"
+ }
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
}
},
"security": [
@@ -1342,6 +1516,10 @@
"connect_to_docker_network": {
"type": "boolean",
"description": "The flag to connect the service to the predefined Docker network."
+ },
+ "force_domain_override": {
+ "type": "boolean",
+ "description": "Force domain usage even if conflicts are detected. Default is false."
}
},
"type": "object"
@@ -1370,6 +1548,60 @@
},
"400": {
"$ref": "#\/components\/responses\/400"
+ },
+ "409": {
+ "description": "Domain conflicts detected.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "Domain conflicts detected. Use force_domain_override=true to proceed."
+ },
+ "warning": {
+ "type": "string",
+ "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior."
+ },
+ "conflicts": {
+ "type": "array",
+ "items": {
+ "properties": {
+ "domain": {
+ "type": "string",
+ "example": "example.com"
+ },
+ "resource_name": {
+ "type": "string",
+ "example": "My Application"
+ },
+ "resource_uuid": {
+ "type": "string",
+ "nullable": true,
+ "example": "abc123-def456"
+ },
+ "resource_type": {
+ "type": "string",
+ "enum": [
+ "application",
+ "service",
+ "instance"
+ ],
+ "example": "application"
+ },
+ "message": {
+ "type": "string",
+ "example": "Domain example.com is already in use by application 'My Application'"
+ }
+ },
+ "type": "object"
+ }
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
}
},
"security": [
@@ -1606,6 +1838,10 @@
"connect_to_docker_network": {
"type": "boolean",
"description": "The flag to connect the service to the predefined Docker network."
+ },
+ "force_domain_override": {
+ "type": "boolean",
+ "description": "Force domain usage even if conflicts are detected. Default is false."
}
},
"type": "object"
@@ -1634,6 +1870,60 @@
},
"400": {
"$ref": "#\/components\/responses\/400"
+ },
+ "409": {
+ "description": "Domain conflicts detected.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "Domain conflicts detected. Use force_domain_override=true to proceed."
+ },
+ "warning": {
+ "type": "string",
+ "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior."
+ },
+ "conflicts": {
+ "type": "array",
+ "items": {
+ "properties": {
+ "domain": {
+ "type": "string",
+ "example": "example.com"
+ },
+ "resource_name": {
+ "type": "string",
+ "example": "My Application"
+ },
+ "resource_uuid": {
+ "type": "string",
+ "nullable": true,
+ "example": "abc123-def456"
+ },
+ "resource_type": {
+ "type": "string",
+ "enum": [
+ "application",
+ "service",
+ "instance"
+ ],
+ "example": "application"
+ },
+ "message": {
+ "type": "string",
+ "example": "Domain example.com is already in use by application 'My Application'"
+ }
+ },
+ "type": "object"
+ }
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
}
},
"security": [
@@ -1709,6 +1999,10 @@
"connect_to_docker_network": {
"type": "boolean",
"description": "The flag to connect the service to the predefined Docker network."
+ },
+ "force_domain_override": {
+ "type": "boolean",
+ "description": "Force domain usage even if conflicts are detected. Default is false."
}
},
"type": "object"
@@ -1737,6 +2031,60 @@
},
"400": {
"$ref": "#\/components\/responses\/400"
+ },
+ "409": {
+ "description": "Domain conflicts detected.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "Domain conflicts detected. Use force_domain_override=true to proceed."
+ },
+ "warning": {
+ "type": "string",
+ "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior."
+ },
+ "conflicts": {
+ "type": "array",
+ "items": {
+ "properties": {
+ "domain": {
+ "type": "string",
+ "example": "example.com"
+ },
+ "resource_name": {
+ "type": "string",
+ "example": "My Application"
+ },
+ "resource_uuid": {
+ "type": "string",
+ "nullable": true,
+ "example": "abc123-def456"
+ },
+ "resource_type": {
+ "type": "string",
+ "enum": [
+ "application",
+ "service",
+ "instance"
+ ],
+ "example": "application"
+ },
+ "message": {
+ "type": "string",
+ "example": "Domain example.com is already in use by application 'My Application'"
+ }
+ },
+ "type": "object"
+ }
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
}
},
"security": [
@@ -2175,6 +2523,10 @@
"connect_to_docker_network": {
"type": "boolean",
"description": "The flag to connect the service to the predefined Docker network."
+ },
+ "force_domain_override": {
+ "type": "boolean",
+ "description": "Force domain usage even if conflicts are detected. Default is false."
}
},
"type": "object"
@@ -2206,6 +2558,60 @@
},
"404": {
"$ref": "#\/components\/responses\/404"
+ },
+ "409": {
+ "description": "Domain conflicts detected.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "Domain conflicts detected. Use force_domain_override=true to proceed."
+ },
+ "warning": {
+ "type": "string",
+ "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior."
+ },
+ "conflicts": {
+ "type": "array",
+ "items": {
+ "properties": {
+ "domain": {
+ "type": "string",
+ "example": "example.com"
+ },
+ "resource_name": {
+ "type": "string",
+ "example": "My Application"
+ },
+ "resource_uuid": {
+ "type": "string",
+ "nullable": true,
+ "example": "abc123-def456"
+ },
+ "resource_type": {
+ "type": "string",
+ "enum": [
+ "application",
+ "service",
+ "instance"
+ ],
+ "example": "application"
+ },
+ "message": {
+ "type": "string",
+ "example": "Domain example.com is already in use by application 'My Application'"
+ }
+ },
+ "type": "object"
+ }
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
}
},
"security": [
@@ -2367,10 +2773,6 @@
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in preview deployments."
},
- "is_build_time": {
- "type": "boolean",
- "description": "The flag to indicate if the environment variable is used in build time."
- },
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
@@ -2464,10 +2866,6 @@
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in preview deployments."
},
- "is_build_time": {
- "type": "boolean",
- "description": "The flag to indicate if the environment variable is used in build time."
- },
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
@@ -2566,10 +2964,6 @@
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in preview deployments."
},
- "is_build_time": {
- "type": "boolean",
- "description": "The flag to indicate if the environment variable is used in build time."
- },
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
@@ -5196,6 +5590,190 @@
]
}
},
+ "\/projects\/{uuid}\/environments": {
+ "get": {
+ "tags": [
+ "Projects"
+ ],
+ "summary": "List Environments",
+ "description": "List all environments in a project.",
+ "operationId": "get-environments",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "Project UUID",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "List of environments",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#\/components\/schemas\/Environment"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "description": "Project not found."
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "post": {
+ "tags": [
+ "Projects"
+ ],
+ "summary": "Create Environment",
+ "description": "Create environment in project.",
+ "operationId": "create-environment",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "Project UUID",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "description": "Environment created.",
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The name of the environment."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Environment created.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "uuid": {
+ "type": "string",
+ "example": "env123",
+ "description": "The UUID of the environment."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "description": "Project not found."
+ },
+ "409": {
+ "description": "Environment with this name already exists."
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/projects\/{uuid}\/environments\/{environment_name_or_uuid}": {
+ "delete": {
+ "tags": [
+ "Projects"
+ ],
+ "summary": "Delete Environment",
+ "description": "Delete environment by name or UUID. Environment must be empty.",
+ "operationId": "delete-environment",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "Project UUID",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "environment_name_or_uuid",
+ "in": "path",
+ "description": "Environment name or UUID",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Environment deleted.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "Environment deleted."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "description": "Environment has resources, so it cannot be deleted."
+ },
+ "404": {
+ "description": "Project or environment not found."
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/resources": {
"get": {
"tags": [
@@ -6412,13 +6990,6 @@
"content": {
"application\/json": {
"schema": {
- "required": [
- "server_uuid",
- "project_uuid",
- "environment_name",
- "environment_uuid",
- "docker_compose_raw"
- ],
"properties": {
"name": {
"type": "string",
@@ -6596,10 +7167,6 @@
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in preview deployments."
},
- "is_build_time": {
- "type": "boolean",
- "description": "The flag to indicate if the environment variable is used in build time."
- },
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
@@ -6693,10 +7260,6 @@
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in preview deployments."
},
- "is_build_time": {
- "type": "boolean",
- "description": "The flag to indicate if the environment variable is used in build time."
- },
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
@@ -6795,10 +7358,6 @@
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in preview deployments."
},
- "is_build_time": {
- "type": "boolean",
- "description": "The flag to indicate if the environment variable is used in build time."
- },
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
@@ -7792,9 +8351,6 @@
"resourceable_id": {
"type": "integer"
},
- "is_build_time": {
- "type": "boolean"
- },
"is_literal": {
"type": "boolean"
},
@@ -7804,6 +8360,12 @@
"is_preview": {
"type": "boolean"
},
+ "is_runtime": {
+ "type": "boolean"
+ },
+ "is_buildtime": {
+ "type": "boolean"
+ },
"is_shared": {
"type": "boolean"
},
@@ -8026,6 +8588,9 @@
"is_swarm_worker": {
"type": "boolean"
},
+ "is_terminal_enabled": {
+ "type": "boolean"
+ },
"is_usable": {
"type": "boolean"
},
diff --git a/openapi.yaml b/openapi.yaml
index 3f2fa1c59..9529fcf87 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -262,6 +262,9 @@ paths:
connect_to_docker_network:
type: boolean
description: 'The flag to connect the service to the predefined Docker network.'
+ force_domain_override:
+ type: boolean
+ description: 'Force domain usage even if conflicts are detected. Default is false.'
type: object
responses:
'201':
@@ -276,6 +279,16 @@ paths:
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
+ '409':
+ description: 'Domain conflicts detected.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' }
+ warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' }
+ conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } }
+ type: object
security:
-
bearerAuth: []
@@ -515,6 +528,9 @@ paths:
connect_to_docker_network:
type: boolean
description: 'The flag to connect the service to the predefined Docker network.'
+ force_domain_override:
+ type: boolean
+ description: 'Force domain usage even if conflicts are detected. Default is false.'
type: object
responses:
'201':
@@ -529,6 +545,16 @@ paths:
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
+ '409':
+ description: 'Domain conflicts detected.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' }
+ warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' }
+ conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } }
+ type: object
security:
-
bearerAuth: []
@@ -768,6 +794,9 @@ paths:
connect_to_docker_network:
type: boolean
description: 'The flag to connect the service to the predefined Docker network.'
+ force_domain_override:
+ type: boolean
+ description: 'Force domain usage even if conflicts are detected. Default is false.'
type: object
responses:
'201':
@@ -782,6 +811,16 @@ paths:
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
+ '409':
+ description: 'Domain conflicts detected.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' }
+ warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' }
+ conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } }
+ type: object
security:
-
bearerAuth: []
@@ -968,6 +1007,9 @@ paths:
connect_to_docker_network:
type: boolean
description: 'The flag to connect the service to the predefined Docker network.'
+ force_domain_override:
+ type: boolean
+ description: 'Force domain usage even if conflicts are detected. Default is false.'
type: object
responses:
'201':
@@ -982,6 +1024,16 @@ paths:
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
+ '409':
+ description: 'Domain conflicts detected.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' }
+ warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' }
+ conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } }
+ type: object
security:
-
bearerAuth: []
@@ -1159,6 +1211,9 @@ paths:
connect_to_docker_network:
type: boolean
description: 'The flag to connect the service to the predefined Docker network.'
+ force_domain_override:
+ type: boolean
+ description: 'Force domain usage even if conflicts are detected. Default is false.'
type: object
responses:
'201':
@@ -1173,6 +1228,16 @@ paths:
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
+ '409':
+ description: 'Domain conflicts detected.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' }
+ warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' }
+ conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } }
+ type: object
security:
-
bearerAuth: []
@@ -1230,6 +1295,9 @@ paths:
connect_to_docker_network:
type: boolean
description: 'The flag to connect the service to the predefined Docker network.'
+ force_domain_override:
+ type: boolean
+ description: 'Force domain usage even if conflicts are detected. Default is false.'
type: object
responses:
'201':
@@ -1244,6 +1312,16 @@ paths:
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
+ '409':
+ description: 'Domain conflicts detected.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' }
+ warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' }
+ conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } }
+ type: object
security:
-
bearerAuth: []
@@ -1560,6 +1638,9 @@ paths:
connect_to_docker_network:
type: boolean
description: 'The flag to connect the service to the predefined Docker network.'
+ force_domain_override:
+ type: boolean
+ description: 'Force domain usage even if conflicts are detected. Default is false.'
type: object
responses:
'200':
@@ -1576,6 +1657,16 @@ paths:
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
+ '409':
+ description: 'Domain conflicts detected.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' }
+ warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' }
+ conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } }
+ type: object
security:
-
bearerAuth: []
@@ -1687,9 +1778,6 @@ paths:
is_preview:
type: boolean
description: 'The flag to indicate if the environment variable is used in preview deployments.'
- is_build_time:
- type: boolean
- description: 'The flag to indicate if the environment variable is used in build time.'
is_literal:
type: boolean
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
@@ -1752,9 +1840,6 @@ paths:
is_preview:
type: boolean
description: 'The flag to indicate if the environment variable is used in preview deployments.'
- is_build_time:
- type: boolean
- description: 'The flag to indicate if the environment variable is used in build time.'
is_literal:
type: boolean
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
@@ -1810,7 +1895,7 @@ paths:
properties:
data:
type: array
- items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_build_time: { type: boolean, description: 'The flag to indicate if the environment variable is used in build time.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object }
+ items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object }
type: object
responses:
'201':
@@ -3570,6 +3655,124 @@ paths:
security:
-
bearerAuth: []
+ '/projects/{uuid}/environments':
+ get:
+ tags:
+ - Projects
+ summary: 'List Environments'
+ description: 'List all environments in a project.'
+ operationId: get-environments
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'Project UUID'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'List of environments'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Environment'
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ description: 'Project not found.'
+ security:
+ -
+ bearerAuth: []
+ post:
+ tags:
+ - Projects
+ summary: 'Create Environment'
+ description: 'Create environment in project.'
+ operationId: create-environment
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'Project UUID'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ description: 'Environment created.'
+ required: true
+ content:
+ application/json:
+ schema:
+ properties:
+ name:
+ type: string
+ description: 'The name of the environment.'
+ type: object
+ responses:
+ '201':
+ description: 'Environment created.'
+ content:
+ application/json:
+ schema:
+ properties:
+ uuid: { type: string, example: env123, description: 'The UUID of the environment.' }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ description: 'Project not found.'
+ '409':
+ description: 'Environment with this name already exists.'
+ security:
+ -
+ bearerAuth: []
+ '/projects/{uuid}/environments/{environment_name_or_uuid}':
+ delete:
+ tags:
+ - Projects
+ summary: 'Delete Environment'
+ description: 'Delete environment by name or UUID. Environment must be empty.'
+ operationId: delete-environment
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'Project UUID'
+ required: true
+ schema:
+ type: string
+ -
+ name: environment_name_or_uuid
+ in: path
+ description: 'Environment name or UUID'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'Environment deleted.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'Environment deleted.' }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ description: 'Environment has resources, so it cannot be deleted.'
+ '404':
+ description: 'Project or environment not found.'
+ security:
+ -
+ bearerAuth: []
/resources:
get:
tags:
@@ -4289,12 +4492,6 @@ paths:
content:
application/json:
schema:
- required:
- - server_uuid
- - project_uuid
- - environment_name
- - environment_uuid
- - docker_compose_raw
properties:
name:
type: string
@@ -4412,9 +4609,6 @@ paths:
is_preview:
type: boolean
description: 'The flag to indicate if the environment variable is used in preview deployments.'
- is_build_time:
- type: boolean
- description: 'The flag to indicate if the environment variable is used in build time.'
is_literal:
type: boolean
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
@@ -4477,9 +4671,6 @@ paths:
is_preview:
type: boolean
description: 'The flag to indicate if the environment variable is used in preview deployments.'
- is_build_time:
- type: boolean
- description: 'The flag to indicate if the environment variable is used in build time.'
is_literal:
type: boolean
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
@@ -4535,7 +4726,7 @@ paths:
properties:
data:
type: array
- items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_build_time: { type: boolean, description: 'The flag to indicate if the environment variable is used in build time.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object }
+ items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object }
type: object
responses:
'201':
@@ -5214,14 +5405,16 @@ components:
type: string
resourceable_id:
type: integer
- is_build_time:
- type: boolean
is_literal:
type: boolean
is_multiline:
type: boolean
is_preview:
type: boolean
+ is_runtime:
+ type: boolean
+ is_buildtime:
+ type: boolean
is_shared:
type: boolean
is_shown_once:
@@ -5377,6 +5570,8 @@ components:
type: boolean
is_swarm_worker:
type: boolean
+ is_terminal_enabled:
+ type: boolean
is_usable:
type: boolean
logdrain_axiom_api_key:
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index 8d362115e..fd5dccaf0 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,19 +1,19 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.420.2"
+ "version": "4.0.0-beta.428"
},
"nightly": {
- "version": "4.0.0-beta.420.3"
+ "version": "4.0.0-beta.429"
},
"helper": {
- "version": "1.0.8"
+ "version": "1.0.11"
},
"realtime": {
- "version": "1.0.9"
+ "version": "1.0.10"
},
"sentinel": {
- "version": "0.0.15"
+ "version": "0.0.16"
}
}
}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 10489a7d4..56e48288c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,7 +22,7 @@
"pusher-js": "8.4.0",
"tailwind-scrollbar": "4.0.2",
"tailwindcss": "4.1.10",
- "vite": "6.3.5",
+ "vite": "6.3.6",
"vue": "3.5.16"
}
},
@@ -90,9 +90,9 @@
}
},
"node_modules/@babel/types": {
- "version": "7.28.1",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz",
- "integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==",
+ "version": "7.28.2",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz",
+ "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -104,9 +104,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz",
- "integrity": "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz",
+ "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==",
"cpu": [
"ppc64"
],
@@ -121,9 +121,9 @@
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.6.tgz",
- "integrity": "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz",
+ "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==",
"cpu": [
"arm"
],
@@ -138,9 +138,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz",
- "integrity": "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz",
+ "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==",
"cpu": [
"arm64"
],
@@ -155,9 +155,9 @@
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.6.tgz",
- "integrity": "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz",
+ "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==",
"cpu": [
"x64"
],
@@ -172,9 +172,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz",
- "integrity": "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz",
+ "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==",
"cpu": [
"arm64"
],
@@ -189,9 +189,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz",
- "integrity": "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz",
+ "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==",
"cpu": [
"x64"
],
@@ -206,9 +206,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz",
- "integrity": "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz",
+ "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==",
"cpu": [
"arm64"
],
@@ -223,9 +223,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz",
- "integrity": "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz",
+ "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==",
"cpu": [
"x64"
],
@@ -240,9 +240,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz",
- "integrity": "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz",
+ "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==",
"cpu": [
"arm"
],
@@ -257,9 +257,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz",
- "integrity": "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz",
+ "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==",
"cpu": [
"arm64"
],
@@ -274,9 +274,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz",
- "integrity": "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz",
+ "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==",
"cpu": [
"ia32"
],
@@ -291,9 +291,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz",
- "integrity": "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz",
+ "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==",
"cpu": [
"loong64"
],
@@ -308,9 +308,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz",
- "integrity": "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz",
+ "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==",
"cpu": [
"mips64el"
],
@@ -325,9 +325,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz",
- "integrity": "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz",
+ "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==",
"cpu": [
"ppc64"
],
@@ -342,9 +342,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz",
- "integrity": "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz",
+ "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==",
"cpu": [
"riscv64"
],
@@ -359,9 +359,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz",
- "integrity": "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz",
+ "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==",
"cpu": [
"s390x"
],
@@ -376,9 +376,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz",
- "integrity": "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz",
+ "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==",
"cpu": [
"x64"
],
@@ -393,9 +393,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz",
- "integrity": "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz",
+ "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==",
"cpu": [
"arm64"
],
@@ -410,9 +410,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz",
- "integrity": "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz",
+ "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==",
"cpu": [
"x64"
],
@@ -427,9 +427,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz",
- "integrity": "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz",
+ "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==",
"cpu": [
"arm64"
],
@@ -444,9 +444,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz",
- "integrity": "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz",
+ "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==",
"cpu": [
"x64"
],
@@ -461,9 +461,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz",
- "integrity": "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz",
+ "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==",
"cpu": [
"arm64"
],
@@ -478,9 +478,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz",
- "integrity": "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz",
+ "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==",
"cpu": [
"x64"
],
@@ -495,9 +495,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz",
- "integrity": "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz",
+ "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==",
"cpu": [
"arm64"
],
@@ -512,9 +512,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz",
- "integrity": "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz",
+ "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==",
"cpu": [
"ia32"
],
@@ -529,9 +529,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz",
- "integrity": "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz",
+ "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==",
"cpu": [
"x64"
],
@@ -546,9 +546,9 @@
}
},
"node_modules/@ioredis/commands": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
- "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.3.0.tgz",
+ "integrity": "sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==",
"license": "MIT"
},
"node_modules/@isaacs/fs-minipass": {
@@ -604,9 +604,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.1.tgz",
- "integrity": "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz",
+ "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==",
"cpu": [
"arm"
],
@@ -618,9 +618,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.1.tgz",
- "integrity": "sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz",
+ "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==",
"cpu": [
"arm64"
],
@@ -632,9 +632,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.1.tgz",
- "integrity": "sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz",
+ "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==",
"cpu": [
"arm64"
],
@@ -646,9 +646,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.1.tgz",
- "integrity": "sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz",
+ "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==",
"cpu": [
"x64"
],
@@ -660,9 +660,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.1.tgz",
- "integrity": "sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz",
+ "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==",
"cpu": [
"arm64"
],
@@ -674,9 +674,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.1.tgz",
- "integrity": "sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz",
+ "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==",
"cpu": [
"x64"
],
@@ -688,9 +688,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.1.tgz",
- "integrity": "sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz",
+ "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==",
"cpu": [
"arm"
],
@@ -702,9 +702,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.1.tgz",
- "integrity": "sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz",
+ "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==",
"cpu": [
"arm"
],
@@ -716,9 +716,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.1.tgz",
- "integrity": "sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz",
+ "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==",
"cpu": [
"arm64"
],
@@ -730,9 +730,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.1.tgz",
- "integrity": "sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz",
+ "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==",
"cpu": [
"arm64"
],
@@ -744,9 +744,9 @@
]
},
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.1.tgz",
- "integrity": "sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz",
+ "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==",
"cpu": [
"loong64"
],
@@ -757,10 +757,10 @@
"linux"
]
},
- "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.1.tgz",
- "integrity": "sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==",
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz",
+ "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==",
"cpu": [
"ppc64"
],
@@ -772,9 +772,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.1.tgz",
- "integrity": "sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz",
+ "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==",
"cpu": [
"riscv64"
],
@@ -786,9 +786,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.1.tgz",
- "integrity": "sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz",
+ "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==",
"cpu": [
"riscv64"
],
@@ -800,9 +800,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.1.tgz",
- "integrity": "sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz",
+ "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==",
"cpu": [
"s390x"
],
@@ -814,9 +814,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz",
- "integrity": "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz",
+ "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==",
"cpu": [
"x64"
],
@@ -828,9 +828,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.1.tgz",
- "integrity": "sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz",
+ "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==",
"cpu": [
"x64"
],
@@ -842,9 +842,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.1.tgz",
- "integrity": "sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz",
+ "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==",
"cpu": [
"arm64"
],
@@ -856,9 +856,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.1.tgz",
- "integrity": "sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz",
+ "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==",
"cpu": [
"ia32"
],
@@ -870,9 +870,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.1.tgz",
- "integrity": "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz",
+ "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==",
"cpu": [
"x64"
],
@@ -1131,6 +1131,66 @@
"node": ">=14.0.0"
}
},
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.4.3",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.0.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.4.3",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.0.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
+ "version": "0.2.10",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.4.3",
+ "@emnapi/runtime": "^1.4.3",
+ "@tybys/wasm-util": "^0.9.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
+ "version": "0.9.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
+ "version": "2.8.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.10.tgz",
@@ -1623,9 +1683,9 @@
}
},
"node_modules/esbuild": {
- "version": "0.25.6",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz",
- "integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==",
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz",
+ "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1636,32 +1696,32 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.25.6",
- "@esbuild/android-arm": "0.25.6",
- "@esbuild/android-arm64": "0.25.6",
- "@esbuild/android-x64": "0.25.6",
- "@esbuild/darwin-arm64": "0.25.6",
- "@esbuild/darwin-x64": "0.25.6",
- "@esbuild/freebsd-arm64": "0.25.6",
- "@esbuild/freebsd-x64": "0.25.6",
- "@esbuild/linux-arm": "0.25.6",
- "@esbuild/linux-arm64": "0.25.6",
- "@esbuild/linux-ia32": "0.25.6",
- "@esbuild/linux-loong64": "0.25.6",
- "@esbuild/linux-mips64el": "0.25.6",
- "@esbuild/linux-ppc64": "0.25.6",
- "@esbuild/linux-riscv64": "0.25.6",
- "@esbuild/linux-s390x": "0.25.6",
- "@esbuild/linux-x64": "0.25.6",
- "@esbuild/netbsd-arm64": "0.25.6",
- "@esbuild/netbsd-x64": "0.25.6",
- "@esbuild/openbsd-arm64": "0.25.6",
- "@esbuild/openbsd-x64": "0.25.6",
- "@esbuild/openharmony-arm64": "0.25.6",
- "@esbuild/sunos-x64": "0.25.6",
- "@esbuild/win32-arm64": "0.25.6",
- "@esbuild/win32-ia32": "0.25.6",
- "@esbuild/win32-x64": "0.25.6"
+ "@esbuild/aix-ppc64": "0.25.8",
+ "@esbuild/android-arm": "0.25.8",
+ "@esbuild/android-arm64": "0.25.8",
+ "@esbuild/android-x64": "0.25.8",
+ "@esbuild/darwin-arm64": "0.25.8",
+ "@esbuild/darwin-x64": "0.25.8",
+ "@esbuild/freebsd-arm64": "0.25.8",
+ "@esbuild/freebsd-x64": "0.25.8",
+ "@esbuild/linux-arm": "0.25.8",
+ "@esbuild/linux-arm64": "0.25.8",
+ "@esbuild/linux-ia32": "0.25.8",
+ "@esbuild/linux-loong64": "0.25.8",
+ "@esbuild/linux-mips64el": "0.25.8",
+ "@esbuild/linux-ppc64": "0.25.8",
+ "@esbuild/linux-riscv64": "0.25.8",
+ "@esbuild/linux-s390x": "0.25.8",
+ "@esbuild/linux-x64": "0.25.8",
+ "@esbuild/netbsd-arm64": "0.25.8",
+ "@esbuild/netbsd-x64": "0.25.8",
+ "@esbuild/openbsd-arm64": "0.25.8",
+ "@esbuild/openbsd-x64": "0.25.8",
+ "@esbuild/openharmony-arm64": "0.25.8",
+ "@esbuild/sunos-x64": "0.25.8",
+ "@esbuild/win32-arm64": "0.25.8",
+ "@esbuild/win32-ia32": "0.25.8",
+ "@esbuild/win32-x64": "0.25.8"
}
},
"node_modules/estree-walker": {
@@ -1687,9 +1747,9 @@
}
},
"node_modules/follow-redirects": {
- "version": "1.15.9",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
- "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"dev": true,
"funding": [
{
@@ -1875,9 +1935,9 @@
}
},
"node_modules/jiti": {
- "version": "2.4.2",
- "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
- "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
+ "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==",
"dev": true,
"license": "MIT",
"bin": {
@@ -2397,9 +2457,9 @@
}
},
"node_modules/react": {
- "version": "19.1.0",
- "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
- "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
+ "version": "19.1.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
+ "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -2429,9 +2489,9 @@
}
},
"node_modules/rollup": {
- "version": "4.45.1",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz",
- "integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==",
+ "version": "4.46.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz",
+ "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2445,26 +2505,26 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.45.1",
- "@rollup/rollup-android-arm64": "4.45.1",
- "@rollup/rollup-darwin-arm64": "4.45.1",
- "@rollup/rollup-darwin-x64": "4.45.1",
- "@rollup/rollup-freebsd-arm64": "4.45.1",
- "@rollup/rollup-freebsd-x64": "4.45.1",
- "@rollup/rollup-linux-arm-gnueabihf": "4.45.1",
- "@rollup/rollup-linux-arm-musleabihf": "4.45.1",
- "@rollup/rollup-linux-arm64-gnu": "4.45.1",
- "@rollup/rollup-linux-arm64-musl": "4.45.1",
- "@rollup/rollup-linux-loongarch64-gnu": "4.45.1",
- "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1",
- "@rollup/rollup-linux-riscv64-gnu": "4.45.1",
- "@rollup/rollup-linux-riscv64-musl": "4.45.1",
- "@rollup/rollup-linux-s390x-gnu": "4.45.1",
- "@rollup/rollup-linux-x64-gnu": "4.45.1",
- "@rollup/rollup-linux-x64-musl": "4.45.1",
- "@rollup/rollup-win32-arm64-msvc": "4.45.1",
- "@rollup/rollup-win32-ia32-msvc": "4.45.1",
- "@rollup/rollup-win32-x64-msvc": "4.45.1",
+ "@rollup/rollup-android-arm-eabi": "4.46.2",
+ "@rollup/rollup-android-arm64": "4.46.2",
+ "@rollup/rollup-darwin-arm64": "4.46.2",
+ "@rollup/rollup-darwin-x64": "4.46.2",
+ "@rollup/rollup-freebsd-arm64": "4.46.2",
+ "@rollup/rollup-freebsd-x64": "4.46.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.46.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.46.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.46.2",
+ "@rollup/rollup-linux-arm64-musl": "4.46.2",
+ "@rollup/rollup-linux-loongarch64-gnu": "4.46.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.46.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.46.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.46.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.46.2",
+ "@rollup/rollup-linux-x64-gnu": "4.46.2",
+ "@rollup/rollup-linux-x64-musl": "4.46.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.46.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.46.2",
+ "@rollup/rollup-win32-x64-msvc": "4.46.2",
"fsevents": "~2.3.2"
}
},
@@ -2635,9 +2695,9 @@
"license": "MIT"
},
"node_modules/vite": {
- "version": "6.3.5",
- "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
- "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
+ "version": "6.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
+ "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index 10ec71415..e29c5e8e6 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"pusher-js": "8.4.0",
"tailwind-scrollbar": "4.0.2",
"tailwindcss": "4.1.10",
- "vite": "6.3.5",
+ "vite": "6.3.6",
"vue": "3.5.16"
},
"dependencies": {
diff --git a/public/js/purify.min.js b/public/js/purify.min.js
new file mode 100644
index 000000000..73df78d60
--- /dev/null
+++ b/public/js/purify.min.js
@@ -0,0 +1,3 @@
+/*! @license DOMPurify 3.2.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.6/LICENSE */
+!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,(function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:n,getPrototypeOf:o,getOwnPropertyDescriptor:r}=Object;let{freeze:i,seal:a,create:l}=Object,{apply:c,construct:s}="undefined"!=typeof Reflect&&Reflect;i||(i=function(e){return e}),a||(a=function(e){return e}),c||(c=function(e,t,n){return e.apply(t,n)}),s||(s=function(e,t){return new e(...t)});const u=R(Array.prototype.forEach),m=R(Array.prototype.lastIndexOf),p=R(Array.prototype.pop),f=R(Array.prototype.push),d=R(Array.prototype.splice),h=R(String.prototype.toLowerCase),g=R(String.prototype.toString),T=R(String.prototype.match),y=R(String.prototype.replace),E=R(String.prototype.indexOf),A=R(String.prototype.trim),_=R(Object.prototype.hasOwnProperty),S=R(RegExp.prototype.test),b=(N=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:h;t&&t(e,null);let i=o.length;for(;i--;){let t=o[i];if("string"==typeof t){const e=r(t);e!==t&&(n(o)||(o[i]=e),t=e)}e[t]=!0}return e}function O(e){for(let t=0;t/gm),G=a(/\$\{[\w\W]*/gm),Y=a(/^data-[\-\w.\u00B7-\uFFFF]+$/),j=a(/^aria-[\-\w]+$/),X=a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),q=a(/^(?:\w+script|data):/i),$=a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),K=a(/^html$/i),V=a(/^[a-z][.\w]*(-[.\w]+)+$/i);var Z=Object.freeze({__proto__:null,ARIA_ATTR:j,ATTR_WHITESPACE:$,CUSTOM_ELEMENT:V,DATA_ATTR:Y,DOCTYPE_NAME:K,ERB_EXPR:W,IS_ALLOWED_URI:X,IS_SCRIPT_OR_DATA:q,MUSTACHE_EXPR:B,TMPLIT_EXPR:G});const J=1,Q=3,ee=7,te=8,ne=9,oe=function(){return"undefined"==typeof window?null:window};var re=function t(){let n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:oe();const o=e=>t(e);if(o.version="3.2.6",o.removed=[],!n||!n.document||n.document.nodeType!==ne||!n.Element)return o.isSupported=!1,o;let{document:r}=n;const a=r,c=a.currentScript,{DocumentFragment:s,HTMLTemplateElement:N,Node:R,Element:O,NodeFilter:B,NamedNodeMap:W=n.NamedNodeMap||n.MozNamedAttrMap,HTMLFormElement:G,DOMParser:Y,trustedTypes:j}=n,q=O.prototype,$=v(q,"cloneNode"),V=v(q,"remove"),re=v(q,"nextSibling"),ie=v(q,"childNodes"),ae=v(q,"parentNode");if("function"==typeof N){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let le,ce="";const{implementation:se,createNodeIterator:ue,createDocumentFragment:me,getElementsByTagName:pe}=r,{importNode:fe}=a;let de={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]};o.isSupported="function"==typeof e&&"function"==typeof ae&&se&&void 0!==se.createHTMLDocument;const{MUSTACHE_EXPR:he,ERB_EXPR:ge,TMPLIT_EXPR:Te,DATA_ATTR:ye,ARIA_ATTR:Ee,IS_SCRIPT_OR_DATA:Ae,ATTR_WHITESPACE:_e,CUSTOM_ELEMENT:Se}=Z;let{IS_ALLOWED_URI:be}=Z,Ne=null;const Re=w({},[...L,...C,...x,...M,...U]);let we=null;const Oe=w({},[...z,...P,...H,...F]);let De=Object.seal(l(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),ve=null,Le=null,Ce=!0,xe=!0,Ie=!1,Me=!0,ke=!1,Ue=!0,ze=!1,Pe=!1,He=!1,Fe=!1,Be=!1,We=!1,Ge=!0,Ye=!1,je=!0,Xe=!1,qe={},$e=null;const Ke=w({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let Ve=null;const Ze=w({},["audio","video","img","source","image","track"]);let Je=null;const Qe=w({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),et="http://www.w3.org/1998/Math/MathML",tt="http://www.w3.org/2000/svg",nt="http://www.w3.org/1999/xhtml";let ot=nt,rt=!1,it=null;const at=w({},[et,tt,nt],g);let lt=w({},["mi","mo","mn","ms","mtext"]),ct=w({},["annotation-xml"]);const st=w({},["title","style","font","a","script"]);let ut=null;const mt=["application/xhtml+xml","text/html"];let pt=null,ft=null;const dt=r.createElement("form"),ht=function(e){return e instanceof RegExp||e instanceof Function},gt=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!ft||ft!==e){if(e&&"object"==typeof e||(e={}),e=D(e),ut=-1===mt.indexOf(e.PARSER_MEDIA_TYPE)?"text/html":e.PARSER_MEDIA_TYPE,pt="application/xhtml+xml"===ut?g:h,Ne=_(e,"ALLOWED_TAGS")?w({},e.ALLOWED_TAGS,pt):Re,we=_(e,"ALLOWED_ATTR")?w({},e.ALLOWED_ATTR,pt):Oe,it=_(e,"ALLOWED_NAMESPACES")?w({},e.ALLOWED_NAMESPACES,g):at,Je=_(e,"ADD_URI_SAFE_ATTR")?w(D(Qe),e.ADD_URI_SAFE_ATTR,pt):Qe,Ve=_(e,"ADD_DATA_URI_TAGS")?w(D(Ze),e.ADD_DATA_URI_TAGS,pt):Ze,$e=_(e,"FORBID_CONTENTS")?w({},e.FORBID_CONTENTS,pt):Ke,ve=_(e,"FORBID_TAGS")?w({},e.FORBID_TAGS,pt):D({}),Le=_(e,"FORBID_ATTR")?w({},e.FORBID_ATTR,pt):D({}),qe=!!_(e,"USE_PROFILES")&&e.USE_PROFILES,Ce=!1!==e.ALLOW_ARIA_ATTR,xe=!1!==e.ALLOW_DATA_ATTR,Ie=e.ALLOW_UNKNOWN_PROTOCOLS||!1,Me=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,ke=e.SAFE_FOR_TEMPLATES||!1,Ue=!1!==e.SAFE_FOR_XML,ze=e.WHOLE_DOCUMENT||!1,Fe=e.RETURN_DOM||!1,Be=e.RETURN_DOM_FRAGMENT||!1,We=e.RETURN_TRUSTED_TYPE||!1,He=e.FORCE_BODY||!1,Ge=!1!==e.SANITIZE_DOM,Ye=e.SANITIZE_NAMED_PROPS||!1,je=!1!==e.KEEP_CONTENT,Xe=e.IN_PLACE||!1,be=e.ALLOWED_URI_REGEXP||X,ot=e.NAMESPACE||nt,lt=e.MATHML_TEXT_INTEGRATION_POINTS||lt,ct=e.HTML_INTEGRATION_POINTS||ct,De=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(De.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(De.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(De.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),ke&&(xe=!1),Be&&(Fe=!0),qe&&(Ne=w({},U),we=[],!0===qe.html&&(w(Ne,L),w(we,z)),!0===qe.svg&&(w(Ne,C),w(we,P),w(we,F)),!0===qe.svgFilters&&(w(Ne,x),w(we,P),w(we,F)),!0===qe.mathMl&&(w(Ne,M),w(we,H),w(we,F))),e.ADD_TAGS&&(Ne===Re&&(Ne=D(Ne)),w(Ne,e.ADD_TAGS,pt)),e.ADD_ATTR&&(we===Oe&&(we=D(we)),w(we,e.ADD_ATTR,pt)),e.ADD_URI_SAFE_ATTR&&w(Je,e.ADD_URI_SAFE_ATTR,pt),e.FORBID_CONTENTS&&($e===Ke&&($e=D($e)),w($e,e.FORBID_CONTENTS,pt)),je&&(Ne["#text"]=!0),ze&&w(Ne,["html","head","body"]),Ne.table&&(w(Ne,["tbody"]),delete ve.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');le=e.TRUSTED_TYPES_POLICY,ce=le.createHTML("")}else void 0===le&&(le=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}}(j,c)),null!==le&&"string"==typeof ce&&(ce=le.createHTML(""));i&&i(e),ft=e}},Tt=w({},[...C,...x,...I]),yt=w({},[...M,...k]),Et=function(e){f(o.removed,{element:e});try{ae(e).removeChild(e)}catch(t){V(e)}},At=function(e,t){try{f(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){f(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e)if(Fe||Be)try{Et(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},_t=function(e){let t=null,n=null;if(He)e=" "+e;else{const t=T(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===ut&&ot===nt&&(e=''+e+"");const o=le?le.createHTML(e):e;if(ot===nt)try{t=(new Y).parseFromString(o,ut)}catch(e){}if(!t||!t.documentElement){t=se.createDocument(ot,"template",null);try{t.documentElement.innerHTML=rt?ce:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),ot===nt?pe.call(t,ze?"html":"body")[0]:ze?t.documentElement:i},St=function(e){return ue.call(e.ownerDocument||e,e,B.SHOW_ELEMENT|B.SHOW_COMMENT|B.SHOW_TEXT|B.SHOW_PROCESSING_INSTRUCTION|B.SHOW_CDATA_SECTION,null)},bt=function(e){return e instanceof G&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof W)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},Nt=function(e){return"function"==typeof R&&e instanceof R};function Rt(e,t,n){u(e,(e=>{e.call(o,t,n,ft)}))}const wt=function(e){let t=null;if(Rt(de.beforeSanitizeElements,e,null),bt(e))return Et(e),!0;const n=pt(e.nodeName);if(Rt(de.uponSanitizeElement,e,{tagName:n,allowedTags:Ne}),Ue&&e.hasChildNodes()&&!Nt(e.firstElementChild)&&S(/<[/\w!]/g,e.innerHTML)&&S(/<[/\w!]/g,e.textContent))return Et(e),!0;if(e.nodeType===ee)return Et(e),!0;if(Ue&&e.nodeType===te&&S(/<[/\w]/g,e.data))return Et(e),!0;if(!Ne[n]||ve[n]){if(!ve[n]&&Dt(n)){if(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,n))return!1;if(De.tagNameCheck instanceof Function&&De.tagNameCheck(n))return!1}if(je&&!$e[n]){const t=ae(e)||e.parentNode,n=ie(e)||e.childNodes;if(n&&t){for(let o=n.length-1;o>=0;--o){const r=$(n[o],!0);r.__removalCount=(e.__removalCount||0)+1,t.insertBefore(r,re(e))}}}return Et(e),!0}return e instanceof O&&!function(e){let t=ae(e);t&&t.tagName||(t={namespaceURI:ot,tagName:"template"});const n=h(e.tagName),o=h(t.tagName);return!!it[e.namespaceURI]&&(e.namespaceURI===tt?t.namespaceURI===nt?"svg"===n:t.namespaceURI===et?"svg"===n&&("annotation-xml"===o||lt[o]):Boolean(Tt[n]):e.namespaceURI===et?t.namespaceURI===nt?"math"===n:t.namespaceURI===tt?"math"===n&&ct[o]:Boolean(yt[n]):e.namespaceURI===nt?!(t.namespaceURI===tt&&!ct[o])&&!(t.namespaceURI===et&&!lt[o])&&!yt[n]&&(st[n]||!Tt[n]):!("application/xhtml+xml"!==ut||!it[e.namespaceURI]))}(e)?(Et(e),!0):"noscript"!==n&&"noembed"!==n&&"noframes"!==n||!S(/<\/no(script|embed|frames)/i,e.innerHTML)?(ke&&e.nodeType===Q&&(t=e.textContent,u([he,ge,Te],(e=>{t=y(t,e," ")})),e.textContent!==t&&(f(o.removed,{element:e.cloneNode()}),e.textContent=t)),Rt(de.afterSanitizeElements,e,null),!1):(Et(e),!0)},Ot=function(e,t,n){if(Ge&&("id"===t||"name"===t)&&(n in r||n in dt))return!1;if(xe&&!Le[t]&&S(ye,t));else if(Ce&&S(Ee,t));else if(!we[t]||Le[t]){if(!(Dt(e)&&(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,e)||De.tagNameCheck instanceof Function&&De.tagNameCheck(e))&&(De.attributeNameCheck instanceof RegExp&&S(De.attributeNameCheck,t)||De.attributeNameCheck instanceof Function&&De.attributeNameCheck(t))||"is"===t&&De.allowCustomizedBuiltInElements&&(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,n)||De.tagNameCheck instanceof Function&&De.tagNameCheck(n))))return!1}else if(Je[t]);else if(S(be,y(n,_e,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==E(n,"data:")||!Ve[e]){if(Ie&&!S(Ae,y(n,_e,"")));else if(n)return!1}else;return!0},Dt=function(e){return"annotation-xml"!==e&&T(e,Se)},vt=function(e){Rt(de.beforeSanitizeAttributes,e,null);const{attributes:t}=e;if(!t||bt(e))return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:we,forceKeepAttr:void 0};let r=t.length;for(;r--;){const i=t[r],{name:a,namespaceURI:l,value:c}=i,s=pt(a),m=c;let f="value"===a?m:A(m);if(n.attrName=s,n.attrValue=f,n.keepAttr=!0,n.forceKeepAttr=void 0,Rt(de.uponSanitizeAttribute,e,n),f=n.attrValue,!Ye||"id"!==s&&"name"!==s||(At(a,e),f="user-content-"+f),Ue&&S(/((--!?|])>)|<\/(style|title)/i,f)){At(a,e);continue}if(n.forceKeepAttr)continue;if(!n.keepAttr){At(a,e);continue}if(!Me&&S(/\/>/i,f)){At(a,e);continue}ke&&u([he,ge,Te],(e=>{f=y(f,e," ")}));const d=pt(e.nodeName);if(Ot(d,s,f)){if(le&&"object"==typeof j&&"function"==typeof j.getAttributeType)if(l);else switch(j.getAttributeType(d,s)){case"TrustedHTML":f=le.createHTML(f);break;case"TrustedScriptURL":f=le.createScriptURL(f)}if(f!==m)try{l?e.setAttributeNS(l,a,f):e.setAttribute(a,f),bt(e)?Et(e):p(o.removed)}catch(t){At(a,e)}}else At(a,e)}Rt(de.afterSanitizeAttributes,e,null)},Lt=function e(t){let n=null;const o=St(t);for(Rt(de.beforeSanitizeShadowDOM,t,null);n=o.nextNode();)Rt(de.uponSanitizeShadowNode,n,null),wt(n),vt(n),n.content instanceof s&&e(n.content);Rt(de.afterSanitizeShadowDOM,t,null)};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,i=null,l=null;if(rt=!e,rt&&(e="\x3c!--\x3e"),"string"!=typeof e&&!Nt(e)){if("function"!=typeof e.toString)throw b("toString is not a function");if("string"!=typeof(e=e.toString()))throw b("dirty is not a string, aborting")}if(!o.isSupported)return e;if(Pe||gt(t),o.removed=[],"string"==typeof e&&(Xe=!1),Xe){if(e.nodeName){const t=pt(e.nodeName);if(!Ne[t]||ve[t])throw b("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof R)n=_t("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),r.nodeType===J&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r);else{if(!Fe&&!ke&&!ze&&-1===e.indexOf("<"))return le&&We?le.createHTML(e):e;if(n=_t(e),!n)return Fe?null:We?ce:""}n&&He&&Et(n.firstChild);const c=St(Xe?e:n);for(;i=c.nextNode();)wt(i),vt(i),i.content instanceof s&&Lt(i.content);if(Xe)return e;if(Fe){if(Be)for(l=me.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(we.shadowroot||we.shadowrootmode)&&(l=fe.call(a,l,!0)),l}let m=ze?n.outerHTML:n.innerHTML;return ze&&Ne["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&S(K,n.ownerDocument.doctype.name)&&(m="\n"+m),ke&&u([he,ge,Te],(e=>{m=y(m,e," ")})),le&&We?le.createHTML(m):m},o.setConfig=function(){gt(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),Pe=!0},o.clearConfig=function(){ft=null,Pe=!1},o.isValidAttribute=function(e,t,n){ft||gt({});const o=pt(e),r=pt(t);return Ot(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&f(de[e],t)},o.removeHook=function(e,t){if(void 0!==t){const n=m(de[e],t);return-1===n?void 0:d(de[e],n,1)[0]}return p(de[e])},o.removeHooks=function(e){de[e]=[]},o.removeAllHooks=function(){de={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},o}();return re}));
+//# sourceMappingURL=purify.min.js.map
diff --git a/public/svgs/bluesky.svg b/public/svgs/bluesky.svg
new file mode 100644
index 000000000..77ebea072
--- /dev/null
+++ b/public/svgs/bluesky.svg
@@ -0,0 +1,3 @@
+
diff --git a/public/svgs/chroma.svg b/public/svgs/chroma.svg
new file mode 100644
index 000000000..930288fbf
--- /dev/null
+++ b/public/svgs/chroma.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/public/svgs/drizzle.jpeg b/public/svgs/drizzle.jpeg
new file mode 100644
index 000000000..d84ff854b
Binary files /dev/null and b/public/svgs/drizzle.jpeg differ
diff --git a/public/svgs/elasticsearch.svg b/public/svgs/elasticsearch.svg
new file mode 100644
index 000000000..bfc5bfb6a
--- /dev/null
+++ b/public/svgs/elasticsearch.svg
@@ -0,0 +1,16 @@
+
diff --git a/public/svgs/homebox.svg b/public/svgs/homebox.svg
new file mode 100644
index 000000000..08670bbb9
--- /dev/null
+++ b/public/svgs/homebox.svg
@@ -0,0 +1,11 @@
+
diff --git a/public/svgs/langfuse.png b/public/svgs/langfuse.png
deleted file mode 100644
index 8dec0fe4a..000000000
Binary files a/public/svgs/langfuse.png and /dev/null differ
diff --git a/public/svgs/langfuse.svg b/public/svgs/langfuse.svg
new file mode 100644
index 000000000..b04e07490
--- /dev/null
+++ b/public/svgs/langfuse.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/svgs/librechat.svg b/public/svgs/librechat.svg
new file mode 100644
index 000000000..36a536d65
--- /dev/null
+++ b/public/svgs/librechat.svg
@@ -0,0 +1,32 @@
+
diff --git a/public/svgs/openpanel.svg b/public/svgs/openpanel.svg
new file mode 100644
index 000000000..8508fc69e
--- /dev/null
+++ b/public/svgs/openpanel.svg
@@ -0,0 +1 @@
+
diff --git a/public/svgs/pihole.svg b/public/svgs/pihole.svg
new file mode 100644
index 000000000..a4efefcc8
--- /dev/null
+++ b/public/svgs/pihole.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/svgs/sequin.svg b/public/svgs/sequin.svg
new file mode 100644
index 000000000..623bc1159
--- /dev/null
+++ b/public/svgs/sequin.svg
@@ -0,0 +1,16 @@
+
diff --git a/resources/css/utilities.css b/resources/css/utilities.css
index d09d7f49c..694ad61a3 100644
--- a/resources/css/utilities.css
+++ b/resources/css/utilities.css
@@ -6,10 +6,31 @@ @utility apexcharts-tooltip-title {
@apply hidden!;
}
+@utility apexcharts-grid-borders {
+ @apply dark:hidden!;
+}
+
@utility apexcharts-xaxistooltip {
@apply hidden!;
}
+@utility apexcharts-tooltip-custom {
+ @apply bg-white dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300 rounded-lg shadow-lg p-3 text-sm;
+ min-width: 160px;
+}
+
+@utility apexcharts-tooltip-custom-value {
+ @apply text-neutral-700 dark:text-neutral-300 mb-1;
+}
+
+@utility apexcharts-tooltip-value-bold {
+ @apply font-bold text-black dark:text-white;
+}
+
+@utility apexcharts-tooltip-custom-title {
+ @apply text-xs text-neutral-500 dark:text-neutral-400 font-medium;
+}
+
@utility input-sticky {
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus:ring-2 focus:ring-neutral-400 dark:focus:ring-coolgray-300;
}
diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php
index 249aa18f9..66a924fb8 100644
--- a/resources/views/auth/forgot-password.blade.php
+++ b/resources/views/auth/forgot-password.blade.php
@@ -4,7 +4,7 @@
Coolify
- {{ __('auth.forgot_password') }}
+ {{ __('auth.forgot_password_heading') }}
diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php
index 42faf517f..8bd8e81fc 100644
--- a/resources/views/auth/login.blade.php
+++ b/resources/views/auth/login.blade.php
@@ -23,7 +23,7 @@
required label="{{ __('input.password') }}" />
- {{ __('auth.forgot_password') }}?
+ {{ __('auth.forgot_password_link') }}
@else
- {{ __('auth.forgot_password') }}?
+ {{ __('auth.forgot_password_link') }}
@endenv
diff --git a/resources/views/components/applications/links.blade.php b/resources/views/components/applications/links.blade.php
index cf9e9c029..26b1cedf5 100644
--- a/resources/views/components/applications/links.blade.php
+++ b/resources/views/components/applications/links.blade.php
@@ -8,133 +8,85 @@
data_get($application, 'previews', collect([]))->count() > 0 ||
data_get($application, 'ports_mappings_array')) &&
data_get($application, 'settings.is_raw_compose_deployment_enabled') !== true)
- @if (data_get($application, 'gitBrancLocation'))
-
-
- Git Repository
-
- @endif
- @if (data_get($application, 'build_pack') === 'dockercompose')
- @foreach (collect(json_decode($this->application->docker_compose_domains)) as $fqdn)
- @if (data_get($fqdn, 'domain'))
- @foreach (explode(',', data_get($fqdn, 'domain')) as $domain)
-
- {{ getFqdnWithoutPort($domain) }}
-
- @endforeach
- @endif
- @endforeach
- @endif
- @if (data_get($application, 'fqdn'))
- @foreach (str(data_get($application, 'fqdn'))->explode(',') as $fqdn)
-
- {{ getFqdnWithoutPort($fqdn) }}
+
+ @if (data_get($application, 'gitBrancLocation'))
+
+
+ Git Repository
- @endforeach
- @endif
- @if (data_get($application, 'previews', collect())->count() > 0)
- @if (data_get($application, 'build_pack') === 'dockercompose')
- @foreach ($application->previews as $preview)
- @foreach (collect(json_decode($preview->docker_compose_domains)) as $fqdn)
- @if (data_get($fqdn, 'domain'))
- @foreach (explode(',', data_get($fqdn, 'domain')) as $domain)
-
- PR{{ data_get($preview, 'pull_request_id') }} |
- {{ getFqdnWithoutPort($domain) }}
-
- @endforeach
- @endif
- @endforeach
- @endforeach
- @else
- @foreach (data_get($application, 'previews') as $preview)
- @if (data_get($preview, 'fqdn'))
-
-
- PR{{ data_get($preview, 'pull_request_id') }} |
- {{ data_get($preview, 'fqdn') }}
-
- @endif
- @endforeach
@endif
- @endif
- @if (data_get($application, 'ports_mappings_array'))
- @foreach ($application->ports_mappings_array as $port)
- @if ($application->destination->server->id === 0)
-
-
- Port {{ $port }}
-
- @else
-
-
- {{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }}
-
- @if (count($application->additional_servers) > 0)
- @foreach ($application->additional_servers as $server)
-
-
- {{ $server->ip }}:{{ explode(':', $port)[0] }}
+ @if (data_get($application, 'build_pack') === 'dockercompose')
+ @foreach (collect(json_decode($this->application->docker_compose_domains)) as $fqdn)
+ @if (data_get($fqdn, 'domain'))
+ @foreach (explode(',', data_get($fqdn, 'domain')) as $domain)
+
+ {{ getFqdnWithoutPort($domain) }}
@endforeach
@endif
+ @endforeach
+ @endif
+ @if (data_get($application, 'fqdn'))
+ @foreach (str(data_get($application, 'fqdn'))->explode(',') as $fqdn)
+
+ {{ getFqdnWithoutPort($fqdn) }}
+
+ @endforeach
+ @endif
+ @if (data_get($application, 'previews', collect())->count() > 0)
+ @if (data_get($application, 'build_pack') === 'dockercompose')
+ @foreach ($application->previews as $preview)
+ @foreach (collect(json_decode($preview->docker_compose_domains)) as $fqdn)
+ @if (data_get($fqdn, 'domain'))
+ @foreach (explode(',', data_get($fqdn, 'domain')) as $domain)
+
+ PR{{ data_get($preview, 'pull_request_id') }}
+ |
+ {{ getFqdnWithoutPort($domain) }}
+
+ @endforeach
+ @endif
+ @endforeach
+ @endforeach
+ @else
+ @foreach (data_get($application, 'previews') as $preview)
+ @if (data_get($preview, 'fqdn'))
+
+
+ PR{{ data_get($preview, 'pull_request_id') }} |
+ {{ data_get($preview, 'fqdn') }}
+
+ @endif
+ @endforeach
@endif
- @endforeach
- @endif
+ @endif
+ @if (data_get($application, 'ports_mappings_array'))
+ @foreach ($application->ports_mappings_array as $port)
+ @if ($application->destination->server->id === 0)
+
+
+ Port {{ $port }}
+
+ @else
+
+
+ {{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }}
+
+ @if (count($application->additional_servers) > 0)
+ @foreach ($application->additional_servers as $server)
+
+
+ {{ $server->ip }}:{{ explode(':', $port)[0] }}
+
+ @endforeach
+ @endif
+ @endif
+ @endforeach
+ @endif
+
@else
No links available
@endif
diff --git a/resources/views/components/domain-conflict-modal.blade.php b/resources/views/components/domain-conflict-modal.blade.php
new file mode 100644
index 000000000..218a7ef16
--- /dev/null
+++ b/resources/views/components/domain-conflict-modal.blade.php
@@ -0,0 +1,91 @@
+@props([
+ 'conflicts' => [],
+ 'showModal' => false,
+ 'confirmAction' => 'confirmDomainUsage',
+])
+
+@if ($showModal && count($conflicts) > 0)
+
+
+
+
+
+
+ Domain Already In Use
+
+
+
+
+ Warning: Domain Conflict Detected
+ {{ $slot ?? '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)
+ -
+
+ {{ $conflict['domain'] }} is used by
+ @if ($conflict['resource_type'] === 'instance')
+ {{ $conflict['resource_name'] }}
+ @else
+
+ {{ $conflict['resource_name'] }}
+
+ @endif
+ ({{ $conflict['resource_type'] }})
+
+
+ @endforeach
+
+
+
+
+ What will happen if you continue?
+ @if (isset($consequences))
+ {{ $consequences }}
+ @else
+
+ - Only one resource will be accessible at this domain
+ - The routing behavior will be unpredictable
+ - You may experience service disruptions
+ - SSL certificates might not work correctly
+
+ @endif
+
+
+
+
+ Cancel
+
+
+ I understand, proceed anyway
+
+
+
+
+
+
+
+@endif
diff --git a/resources/views/components/external-link.blade.php b/resources/views/components/external-link.blade.php
index ddf03427f..9e68704cd 100644
--- a/resources/views/components/external-link.blade.php
+++ b/resources/views/components/external-link.blade.php
@@ -1,7 +1,6 @@
-
@else
@@ -45,7 +46,8 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov
max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}"
maxlength="{{ $attributes->get('maxlength') }}"
@if ($id !== 'null') id={{ $id }} @endif name="{{ $name }}"
- placeholder="{{ $attributes->get('placeholder') }}">
+ placeholder="{{ $attributes->get('placeholder') }}"
+ @if ($autofocus) x-ref="autofocusInput" @endif>
@endif
@if (!$label && $helper)
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php
index f5a0ca84a..1c82614a6 100644
--- a/resources/views/components/modal-confirmation.blade.php
+++ b/resources/views/components/modal-confirmation.blade.php
@@ -11,6 +11,7 @@
'content' => null,
'checkboxes' => [],
'actions' => [],
+ 'warningMessage' => null,
'confirmWithText' => true,
'confirmationText' => 'Confirm Deletion',
'confirmationLabel' => 'Please confirm the execution of the actions by entering the Name below',
@@ -42,7 +43,11 @@
deleteText: '',
password: '',
actions: @js($actions),
- confirmationText: @js(html_entity_decode($confirmationText, ENT_QUOTES, 'UTF-8')),
+ confirmationText: (() => {
+ const textarea = document.createElement('textarea');
+ textarea.innerHTML = @js($confirmationText);
+ return textarea.value;
+ })(),
userConfirmationText: '',
confirmWithText: @js($confirmWithText && !$disableTwoStepConfirmation),
confirmWithPassword: @js($confirmWithPassword && !$disableTwoStepConfirmation),
@@ -224,7 +229,7 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
Warning
- This operation is permanent and cannot be undone. Please think again before proceeding!
+
{!! $warningMessage ?: 'This operation is permanent and cannot be undone. Please think again before proceeding!' !!}
The following actions will be performed:
@@ -257,8 +262,21 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
Confirm Actions
{{ $confirmationLabel }}
-
-
+
+
+
+
+
-
+
+You have requested to change your email address to: {{ $newEmail }}
+
+Please use the following verification code to confirm this change:
+
+Verification Code: {{ $verificationCode }}
+
+This code is valid for {{ $expiryMinutes }} minutes.
+
+If you did not request this change, please ignore this email and your email address will remain unchanged.
+
\ No newline at end of file
diff --git a/resources/views/errors/400.blade.php b/resources/views/errors/400.blade.php
index 2276f5a62..4b5956142 100644
--- a/resources/views/errors/400.blade.php
+++ b/resources/views/errors/400.blade.php
@@ -6,13 +6,17 @@
@if ($exception->getMessage())
{{ $exception->getMessage() }}
@else
- The request could not be understood by the server due to
+
The request could not be understood by the
+ server due to
malformed syntax.
@endif
-
-
- Go back home
+
+
+ Go back
+
+
+ Dashboard
Contact
support
diff --git a/resources/views/errors/401.blade.php b/resources/views/errors/401.blade.php
index e0a44aed8..95449c141 100644
--- a/resources/views/errors/401.blade.php
+++ b/resources/views/errors/401.blade.php
@@ -3,11 +3,14 @@
401
You shall not pass!
- You don't have permission to access this page.
+
You don't have permission to access this page.
-
-
- Go back home
+
+
+ Go back
+
+
+ Dashboard
Contact
support
diff --git a/resources/views/errors/402.blade.php b/resources/views/errors/402.blade.php
index 9758dec2d..6534615df 100644
--- a/resources/views/errors/402.blade.php
+++ b/resources/views/errors/402.blade.php
@@ -4,9 +4,12 @@
402
Payment required.
-
-
- Go back home
+
+
+ Go back
+
+
+ Dashboard
Contact
support
diff --git a/resources/views/errors/403.blade.php b/resources/views/errors/403.blade.php
index f54a2866a..50317700d 100644
--- a/resources/views/errors/403.blade.php
+++ b/resources/views/errors/403.blade.php
@@ -3,11 +3,14 @@
403
You shall not pass!
- You don't have permission to access this page.
+
You don't have permission to access this page.
-
-
- Go back home
+
+
+ Go back
+
+
+ Dashboard
Contact
support
diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php
index 569488d19..67fb0f0f1 100644
--- a/resources/views/errors/404.blade.php
+++ b/resources/views/errors/404.blade.php
@@ -3,12 +3,15 @@
404
How did you get here?
- Sorry, we couldn’t find the page you’re looking
+
Sorry, we couldn’t find the page you’re looking
for.
-
-
- Go back home
+
+
+ Go back
+
+
+ Dashboard
Contact
support
diff --git a/resources/views/errors/419.blade.php b/resources/views/errors/419.blade.php
index 723ba9f55..5367898f0 100644
--- a/resources/views/errors/419.blade.php
+++ b/resources/views/errors/419.blade.php
@@ -3,12 +3,15 @@
419
This page is definitely old, not like you!
- Sorry, we couldn’t find the page you’re looking
+
Sorry, we couldn’t find the page you’re looking
for.
-
-
- Go back home
+
+
+ Go back
+
+
+ Dashboard
Contact
support
diff --git a/resources/views/errors/429.blade.php b/resources/views/errors/429.blade.php
index 443244351..36c8e95f6 100644
--- a/resources/views/errors/429.blade.php
+++ b/resources/views/errors/429.blade.php
@@ -3,12 +3,16 @@
429
Woah, slow down there!
- You're making too many requests. Please wait a few
+
You're making too many requests. Please wait a
+ few
seconds before trying again.
-
-
- Go back home
+
+
+ Go back
+
+
+ Dashboard
Contact
support
diff --git a/resources/views/errors/500.blade.php b/resources/views/errors/500.blade.php
index cc672a324..149be2685 100644
--- a/resources/views/errors/500.blade.php
+++ b/resources/views/errors/500.blade.php
@@ -3,18 +3,22 @@
500
Wait, this is not cool...
- There has been an error with the following error message:
+ There has been an error with the following
+ error message:
@if ($exception->getMessage() !== '')
{!! Purify::clean($exception->getMessage()) !!}
@endif
-
-
- Go back home
+
diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php
index 668ea5e3a..7db859624 100644
--- a/resources/views/errors/503.blade.php
+++ b/resources/views/errors/503.blade.php
@@ -3,10 +3,17 @@
503
We are working on serious things.
- Service Unavailable. Be right back. Thanks for your
+
Service Unavailable. Be right back. Thanks for
+ your
patience.
-
+
+
+ Go back
+
+
+ Dashboard
+
Contact
support
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php
index d9975c975..c074412d3 100644
--- a/resources/views/layouts/base.blade.php
+++ b/resources/views/layouts/base.blade.php
@@ -35,9 +35,9 @@
@endphp
{{ $name }}{{ $title ?? 'Coolify' }}
@env('local')
-
-@else
-
+
+ @else
+
@endenv
@vite(['resources/js/app.js', 'resources/css/app.css'])
@@ -54,6 +54,7 @@
+
@endauth
@section('body')
@@ -61,6 +62,67 @@
- Memory (MB)
+ Memory Usage
+
@endif
@endif
+
diff --git a/resources/views/livewire/project/shared/resource-limits.blade.php b/resources/views/livewire/project/shared/resource-limits.blade.php
index 6c84335de..2aa2fd0af 100644
--- a/resources/views/livewire/project/shared/resource-limits.blade.php
+++ b/resources/views/livewire/project/shared/resource-limits.blade.php
@@ -2,40 +2,39 @@
diff --git a/resources/views/livewire/project/shared/resource-operations.blade.php b/resources/views/livewire/project/shared/resource-operations.blade.php
index f23c6eb4e..aa8f536ce 100644
--- a/resources/views/livewire/project/shared/resource-operations.blade.php
+++ b/resources/views/livewire/project/shared/resource-operations.blade.php
@@ -1,30 +1,41 @@
Resource Operations
You can easily make different kind of operations on this resource.
- Clone
+ Clone
To another project / environment on a different / same server.
- @foreach ($servers->sortBy('id') as $server)
- Server: {{ $server->name }}
- @foreach ($server->destinations() as $destination)
-
-
-
-
- Network
- {{ $destination->name }}
+ @can('update', $resource)
+ @foreach ($servers->sortBy('id') as $server)
+ Server: {{ $server->name }}
+ @foreach ($server->destinations() as $destination)
+
+
+
+
+ Network
+ {{ $destination->name }}
+
-
-
-
+
+
+ @endforeach
@endforeach
- @endforeach
+ @else
+
+
+ Access Restricted: You don't have permission to clone resources. Contact your team
+ administrator to request access.
+
+
+ @endcan
Move
@@ -36,28 +47,38 @@ class="font-bold dark:text-warning">{{ $resource->environment->project->name }}
{{ $resource->environment->name }} environment.
- @forelse ($projects as $project)
- Project: {{ $project->name }}
+ @can('update', $resource)
+ @forelse ($projects as $project)
+ Project: {{ $project->name }}
- @foreach ($project->environments as $environment)
-
-
-
-
- Environment
- {{ $environment->name }}
+ @foreach ($project->environments as $environment)
+
+
+
+
+ Environment
+ {{ $environment->name }}
+
-
-
-
- @endforeach
- @empty
- No projects found to move to
- @endforelse
+
+
+ @endforeach
+ @empty
+ No projects found to move to
+ @endforelse
+ @else
+
+
+ Access Restricted: You don't have permission to move resources between projects or
+ environments. Contact your team administrator to request access.
+
+
+ @endcan
diff --git a/resources/views/livewire/project/shared/scheduled-task/all.blade.php b/resources/views/livewire/project/shared/scheduled-task/all.blade.php
index 8a2ec4d7a..fb6985094 100644
--- a/resources/views/livewire/project/shared/scheduled-task/all.blade.php
+++ b/resources/views/livewire/project/shared/scheduled-task/all.blade.php
@@ -1,13 +1,15 @@
Scheduled Tasks
-
- @if ($resource->type() == 'application')
-
- @elseif ($resource->type() == 'service')
-
- @endif
-
+ @can('update', $resource)
+
+ @if ($resource->type() == 'application')
+
+ @elseif ($resource->type() == 'service')
+
+ @endif
+
+ @endcan
@forelse($resource->scheduled_tasks as $task)
diff --git a/resources/views/livewire/project/shared/scheduled-task/executions.blade.php b/resources/views/livewire/project/shared/scheduled-task/executions.blade.php
index 8f0f309c6..2ed3adc0c 100644
--- a/resources/views/livewire/project/shared/scheduled-task/executions.blade.php
+++ b/resources/views/livewire/project/shared/scheduled-task/executions.blade.php
@@ -14,7 +14,7 @@
}">
@forelse($executions as $execution)
data_get($execution, 'id') == $selectedKey,
'border-blue-500/50 border-dashed' => data_get($execution, 'status') === 'running',
'border-error' => data_get($execution, 'status') === 'failed',
@@ -67,18 +67,22 @@
@endif
@if ($this->logLines->isNotEmpty())
-
+
@else
diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php
index e357b4f94..d62362562 100644
--- a/resources/views/livewire/project/shared/storages/all.blade.php
+++ b/resources/views/livewire/project/shared/storages/all.blade.php
@@ -3,11 +3,10 @@
@foreach ($resource->persistentStorages as $storage)
@if ($resource->type() === 'service')
+ :resource="$resource" :isFirst="$storage->id === $this->firstStorageId" isService='true' />
@else
+ :resource="$resource" :isFirst="$storage->id === $this->firstStorageId" startedAt="{{ data_get($resource, 'started_at') }}" />
@endif
@endforeach
diff --git a/resources/views/livewire/project/shared/storages/show.blade.php b/resources/views/livewire/project/shared/storages/show.blade.php
index 4ad5636ec..569df0c4b 100644
--- a/resources/views/livewire/project/shared/storages/show.blade.php
+++ b/resources/views/livewire/project/shared/storages/show.blade.php
@@ -9,7 +9,7 @@
@else
-
@endif
@if ($isService || $startedAt)
@@ -19,13 +19,11 @@
@else
-
-
- Update
-
@endif
@else
@@ -36,32 +34,50 @@
@endif
@else
- @if ($isFirst)
-
-
-
-
+ @can('update', $resource)
+ @if ($isFirst)
+
+
+
+
+
+ @else
+
+
+
+
+
+ @endif
+
+
+ Update
+
+
@else
-
-
-
-
-
- @endif
-
-
- Update
-
-
-
+ @if ($isFirst)
+
+
+
+
+
+ @else
+
+
+
+
+
+ @endif
+ @endcan
@endif
diff --git a/resources/views/livewire/project/shared/tags.blade.php b/resources/views/livewire/project/shared/tags.blade.php
index 4ceb475a6..2c75deab9 100644
--- a/resources/views/livewire/project/shared/tags.blade.php
+++ b/resources/views/livewire/project/shared/tags.blade.php
@@ -1,37 +1,50 @@
Tags
-
diff --git a/resources/views/livewire/project/shared/webhooks.blade.php b/resources/views/livewire/project/shared/webhooks.blade.php
index 64a2ece51..f3403a402 100644
--- a/resources/views/livewire/project/shared/webhooks.blade.php
+++ b/resources/views/livewire/project/shared/webhooks.blade.php
@@ -17,10 +17,15 @@
-
-
+ @can('update', $resource)
+
+ @else
+
+ @endcan
Webhook Configuration on GitHub
@@ -29,23 +34,43 @@
-
+ @can('update', $resource)
+
+ @else
+
+ @endcan
-
+ @can('update', $resource)
+
+ @else
+
+ @endcan
-
+ @can('update', $resource)
+
+ @else
+
+ @endcan
- Save
+ @can('update', $resource)
+ Save
+ @endcan
@else
You are using an official Git App. You do not need manual webhooks.
diff --git a/resources/views/livewire/project/show.blade.php b/resources/views/livewire/project/show.blade.php
index 3bf427561..3d034b8f3 100644
--- a/resources/views/livewire/project/show.blade.php
+++ b/resources/views/livewire/project/show.blade.php
@@ -4,15 +4,19 @@
Environments
-
-
-
-
- Save
-
-
-
-
+ @can('update', $project)
+
+
+
+
+ Save
+
+
+
+ @endcan
+ @can('delete', $project)
+
+ @endcan
{{ $project->name }}.
@@ -25,12 +29,14 @@
{{ $environment->description }}
-
-
- Settings
-
-
+ @can('update', $project)
+
+
+ Settings
+
+
+ @endcan
@empty
diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php
index eaf7a439d..bf6bcf76c 100644
--- a/resources/views/livewire/security/api-tokens.blade.php
+++ b/resources/views/livewire/security/api-tokens.blade.php
@@ -12,44 +12,58 @@
Tokens are created with the current team as scope.
New Token
-
-
-
- Create
-
-
- Permissions
- :
-
- @if ($permissions)
- @foreach ($permissions as $permission)
- {{ $permission }}
- @endforeach
+ @can('create', App\Models\PersonalAccessToken::class)
+
+
+
+ Create
+
+
+ Permissions
+ :
+
+ @if ($permissions)
+ @foreach ($permissions as $permission)
+ {{ $permission }}
+ @endforeach
+ @endif
+
+
+
+ Token Permissions
+
+ @if ($canUseRootPermissions)
+
+ @else
+
+ @endif
+
+ @if (!in_array('root', $permissions))
+ @if ($canUseWritePermissions)
+
+ @else
+
+ @endif
+
+
+
+
@endif
-
-
- Token Permissions
-
-
- @if (!in_array('root', $permissions))
-
-
-
-
+ @if (in_array('root', $permissions))
+ Root access, be careful!
@endif
-
- @if (in_array('root', $permissions))
- Root access, be careful!
- @endif
-
+
+ @endcan
@if (session()->has('token'))
Please copy this token now. For your security, it won't be shown
again.
@@ -72,15 +86,17 @@ class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underlin
@endif
-
+ @if (auth()->id() === $token->tokenable_id)
+
+ @endif
@empty
diff --git a/resources/views/livewire/security/private-key/create.blade.php b/resources/views/livewire/security/private-key/create.blade.php
index 132c0e9ad..4294823e0 100644
--- a/resources/views/livewire/security/private-key/create.blade.php
+++ b/resources/views/livewire/security/private-key/create.blade.php
@@ -1,5 +1,5 @@
-
+
Private Keys are used to connect to your servers without passwords.
You should not use passphrase protected keys.
diff --git a/resources/views/livewire/security/private-key/index.blade.php b/resources/views/livewire/security/private-key/index.blade.php
index f40b91f43..47cfc9b1e 100644
--- a/resources/views/livewire/security/private-key/index.blade.php
+++ b/resources/views/livewire/security/private-key/index.blade.php
@@ -2,11 +2,15 @@
Private Keys
-
-
-
-
+ @can('create', App\Models\PrivateKey::class)
+
+
+
+ @endcan
+ @can('create', App\Models\PrivateKey::class)
+
+ @endcan
@forelse ($privateKeys as $key)
diff --git a/resources/views/livewire/security/private-key/show.blade.php b/resources/views/livewire/security/private-key/show.blade.php
index c381b8f2a..8668cfd34 100644
--- a/resources/views/livewire/security/private-key/show.blade.php
+++ b/resources/views/livewire/security/private-key/show.blade.php
@@ -7,32 +7,34 @@
Private Key
-
+
Save
@if (data_get($private_key, 'id') > 0)
-
+ @can('delete', $private_key)
+
+ @endcan
@endif
-
-
+
+
Public Key
-
+
Private Key *
@endif
-
-
+
diff --git a/resources/views/livewire/server/advanced.blade.php b/resources/views/livewire/server/advanced.blade.php
index 98ab15534..308a9eed2 100644
--- a/resources/views/livewire/server/advanced.blade.php
+++ b/resources/views/livewire/server/advanced.blade.php
@@ -9,7 +9,7 @@
Advanced
- Save
+ Save
Advanced configuration for your server.
@@ -59,10 +59,10 @@ class="px-2 py-1 text-xs font-semibold text-red-800 bg-red-100 rounded dark:text
-
-
@@ -71,9 +71,11 @@ class="px-2 py-1 text-xs font-semibold text-red-800 bg-red-100 rounded dark:text
Builds
-
-
diff --git a/resources/views/livewire/server/ca-certificate/show.blade.php b/resources/views/livewire/server/ca-certificate/show.blade.php
index 66262614c..f11bd732e 100644
--- a/resources/views/livewire/server/ca-certificate/show.blade.php
+++ b/resources/views/livewire/server/ca-certificate/show.blade.php
@@ -8,28 +8,30 @@
CA Certificate
-
-
-
-
-
-
+ @can('update', $server)
+
+
+
+
+
+
+ @endcan
@@ -63,9 +65,11 @@
@endif
-
- {{ $showCertificate ? 'Hide' : 'Show' }}
-
+ @can('view', $server)
+
+ {{ $showCertificate ? 'Hide' : 'Show' }}
+
+ @endcan
@if ($showCertificate)
Metrics
- Basic metrics for your container.
+ Basic metrics for your server.
@if ($server->isMetricsEnabled())
@@ -19,7 +19,7 @@
- CPU (%)
+ CPU Usage
- Memory (%)
+ Memory Usage