diff --git a/AGENTS.md b/AGENTS.md index 86fc0f00b..5563a18ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,6 +179,7 @@ ## Key Conventions - Run `vendor/bin/pint --dirty --format agent` before finalizing changes - Every change must have tests — write or update tests, then run them. For bug fixes, follow TDD: write a failing test first, then fix the bug (see Test Enforcement below) - Check sibling files for conventions before creating new files +- When adding remote shell commands, account for servers using non-root SSH users: commands pass through `parseCommandsByLineForSudo()`, so test pipelines, redirects, substitutions, and `sh -c`/`bash -c` scripts with the non-root sudo parser. ## Git Workflow diff --git a/app/Actions/Application/CleanupPreviewDeployment.php b/app/Actions/Application/CleanupPreviewDeployment.php index 74e2ff615..803eef398 100644 --- a/app/Actions/Application/CleanupPreviewDeployment.php +++ b/app/Actions/Application/CleanupPreviewDeployment.php @@ -54,6 +54,14 @@ public function handle( $server ); + if ($result['cancelled_deployments'] > 0) { + try { + next_after_cancel($server); + } catch (\Throwable $e) { + \Log::warning("Failed to advance deployment queue after cleaning up preview for application {$application->id}: {$e->getMessage()}"); + } + } + // Step 2: Stop and remove all running PR containers $result['killed_containers'] = $this->stopRunningContainers( $application, @@ -98,13 +106,13 @@ private function cancelActiveDeployments( $deployment->update([ 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); + $cancelled++; // Add cancellation log entry $deployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr'); // Try to kill helper container if it exists $this->killHelperContainer($deployment->deployment_uuid, $server); - $cancelled++; } catch (\Throwable $e) { \Log::warning("Failed to cancel deployment {$deployment->id}: {$e->getMessage()}"); } diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index f44008085..2f030ae29 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -208,11 +208,11 @@ public function handle(StandaloneMariadb $database) $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + if ($this->database->enable_ssl) { + $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt < /dev/null"; + } $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; - if ($this->database->enable_ssl) { - $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt"; - } $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index b31251ce2..097e19f7b 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -257,11 +257,11 @@ public function handle(StandaloneMongodb $database) $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + if ($this->database->enable_ssl) { + $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mongodb:mongodb /etc/mongo/certs/server.pem < /dev/null"; + } $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; - if ($this->database->enable_ssl) { - $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mongodb:mongodb /etc/mongo/certs/server.pem"; - } $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 16b8e1585..d21ee02fb 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -209,11 +209,11 @@ public function handle(StandaloneMysql $database) $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + if ($this->database->enable_ssl) { + $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt < /dev/null"; + } $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; - if ($this->database->enable_ssl) { - $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt"; - } $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 3b0f820df..f70e8f3cf 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -219,11 +219,11 @@ public function handle(StandalonePostgresql $database) $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + if ($this->database->enable_ssl) { + $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name postgres:postgres /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt < /dev/null"; + } $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; - if ($this->database->enable_ssl) { - $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name postgres:postgres /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt"; - } $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index 159f12252..d09aae802 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -13,6 +13,8 @@ class GetProxyConfiguration { use AsAction; + public const MAX_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024; + public function handle(Server $server, bool $forceRegenerate = false): string { $proxyType = $server->proxyType(); @@ -98,11 +100,17 @@ private function configMatchesProxyType(string $proxyType, string $configuration private function backfillFromDisk(Server $server): ?string { $proxy_path = $server->proxyPath(); + $configurationPath = escapeshellarg("$proxy_path/docker-compose.yml"); + $readLimit = self::MAX_CONFIGURATION_SIZE_BYTES + 1; $result = instant_remote_process([ "mkdir -p $proxy_path", - "cat $proxy_path/docker-compose.yml 2>/dev/null", + "if [ ! -f {$configurationPath} ]; then exit 0; elif [ \"$(wc -c < {$configurationPath})\" -gt ".self::MAX_CONFIGURATION_SIZE_BYTES." ]; then echo '__COOLIFY_PROXY_CONFIG_TOO_LARGE__'; else head -c {$readLimit} {$configurationPath}; fi", ], $server, false); + if ($result === '__COOLIFY_PROXY_CONFIG_TOO_LARGE__' || strlen($result ?? '') > self::MAX_CONFIGURATION_SIZE_BYTES) { + throw new \RuntimeException('Proxy configuration exceeds the 5 MiB size limit.'); + } + if (! empty(trim($result ?? ''))) { $server->proxy->last_saved_proxy_configuration = $result; $server->save(); diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index e06516188..04fe00ad4 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -131,7 +131,7 @@ private function buildImagePruneCommand( $commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ". $grepCommands.' | '. - "xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed\\\"}}}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true"; + "xargs -r -I {} sh -c 'docker inspect --format \"{{index .Config.Labels \\\"coolify.managed\\\"}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true"; return implode(' && ', $commands); } diff --git a/app/Actions/Service/UpdateServiceApplicationFromApi.php b/app/Actions/Service/UpdateServiceApplicationFromApi.php index 9d97c4738..123b752c0 100644 --- a/app/Actions/Service/UpdateServiceApplicationFromApi.php +++ b/app/Actions/Service/UpdateServiceApplicationFromApi.php @@ -88,6 +88,10 @@ public function execute(ServiceApplication $serviceApplication, Request $request $serviceApplication->is_stripprefix_enabled = filter_var($payload['is_stripprefix_enabled'], FILTER_VALIDATE_BOOLEAN); } + if (array_key_exists('is_force_https_enabled', $payload)) { + $serviceApplication->is_force_https_enabled = filter_var($payload['is_force_https_enabled'], FILTER_VALIDATE_BOOLEAN); + } + if (array_key_exists('is_log_drain_enabled', $payload)) { $enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN); $server = $serviceApplication->service->destination->server; diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 396844cb0..a0f0cc1ae 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -238,57 +238,71 @@ public function cancel_deployment(Request $request) ApplicationDeploymentStatus::IN_PROGRESS->value, ]; - if (! in_array($deployment->status, $cancellableStatuses)) { + if (! in_array($deployment->status, $cancellableStatuses, true)) { return response()->json([ 'message' => "Deployment cannot be cancelled. Current status: {$deployment->status}", ], 400); } // Perform the cancellation + $cancelled = false; + $deploymentServer = Server::whereTeamId($teamId)->find($deployment->server_id); + try { $deployment_uuid = $deployment->deployment_uuid; $kill_command = "docker rm -f {$deployment_uuid}"; $build_server_id = $deployment->build_server_id ?? $deployment->server_id; // Mark deployment as cancelled - $deployment->update([ - 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, - ]); + $updated = ApplicationDeploymentQueue::whereKey($deployment->getKey()) + ->whereIn('status', $cancellableStatuses) + ->update(['status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value]); + + if ($updated !== 1) { + $deployment->refresh(); + + return response()->json([ + 'message' => "Deployment cannot be cancelled. Current status: {$deployment->status}", + ], 400); + } + + $deployment->status = ApplicationDeploymentStatus::CANCELLED_BY_USER->value; + $cancelled = true; // Get the server $server = Server::whereTeamId($teamId)->find($build_server_id); - if ($server) { - // Add cancellation log entry - $deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr'); + try { + if ($server) { + // Add cancellation log entry + $deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr'); - // Check if container exists and kill it - $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; - $containerExists = instant_remote_process([$checkCommand], $server); + // Check if container exists and kill it + $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; + $containerExists = instant_remote_process([$checkCommand], $server); - if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { - instant_remote_process([$kill_command], $server); - $deployment->addLogEntry('Deployment container stopped.'); - } else { - $deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.'); - } + if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { + instant_remote_process([$kill_command], $server); + $deployment->addLogEntry('Deployment container stopped.'); + } else { + $deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.'); + } - // Kill running process if process ID exists - if ($deployment->current_process_id) { - try { + // Kill running process if process ID exists + if ($deployment->current_process_id) { $processKillCommand = "kill -9 {$deployment->current_process_id}"; instant_remote_process([$processKillCommand], $server); - } catch (\Throwable $e) { - // Process might already be gone } } + } catch (\Throwable $e) { + \Log::warning("Failed to clean up cancelled deployment {$deployment->id}: {$e->getMessage()}"); } auditLog('api.deployment.cancelled', [ 'team_id' => $teamId, 'deployment_uuid' => $deployment->deployment_uuid, - 'application_id' => $application?->id, - 'application_uuid' => $application?->uuid, + 'application_id' => $deployment->application_id, + 'application_uuid' => $deployment->application?->uuid, 'server_id' => $deployment->server_id, ]); @@ -301,6 +315,14 @@ public function cancel_deployment(Request $request) return response()->json([ 'message' => 'Failed to cancel deployment: '.$e->getMessage(), ], 500); + } finally { + if ($cancelled) { + try { + next_after_cancel($deploymentServer); + } catch (\Throwable $e) { + \Log::warning("Failed to advance deployment queue after cancelling deployment {$deployment->id}: {$e->getMessage()}"); + } + } } } diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index 414aff035..e8446467d 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -256,6 +256,7 @@ public function show(Request $request): JsonResponse 'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true), 'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true), 'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true), + 'is_force_https_enabled' => new OA\Property(property: 'is_force_https_enabled', type: 'boolean', nullable: true), ] ) ) @@ -328,6 +329,7 @@ public function update(Request $request, UpdateServiceApplicationFromApi $update 'is_log_drain_enabled', 'is_gzip_enabled', 'is_stripprefix_enabled', + 'is_force_https_enabled', ]; $validationRules = [ @@ -341,6 +343,7 @@ public function update(Request $request, UpdateServiceApplicationFromApi $update 'is_log_drain_enabled' => 'sometimes|boolean', 'is_gzip_enabled' => 'sometimes|boolean', 'is_stripprefix_enabled' => 'sometimes|boolean', + 'is_force_https_enabled' => 'sometimes|boolean', ]; $validator = Validator::make($payload, $validationRules); diff --git a/app/Http/Controllers/Api/VolumeBackupsController.php b/app/Http/Controllers/Api/VolumeBackupsController.php index e51bf31f8..26ff938a1 100644 --- a/app/Http/Controllers/Api/VolumeBackupsController.php +++ b/app/Http/Controllers/Api/VolumeBackupsController.php @@ -34,7 +34,7 @@ new OA\Property(property: 'retention_amount_s3', type: 'integer', default: 7, minimum: 0, maximum: 10000), new OA\Property(property: 'retention_days_s3', type: 'integer', default: 0, maximum: 2147483647, minimum: 0), new OA\Property(property: 'retention_max_storage_s3', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0), - new OA\Property(property: 'timeout', type: 'integer', default: 3600, minimum: 60, maximum: 36000), + new OA\Property(property: 'timeout', type: 'integer', default: ScheduledVolumeBackup::DEFAULT_TIMEOUT, minimum: 60, maximum: 36000), ], type: 'object', additionalProperties: false, @@ -261,7 +261,7 @@ private function persistSchedule( string $resourceType, Model $resource, ): JsonResponse { - $backup = $storage->scheduledBackups()->updateOrCreate([], [ + $attributes = [ 'team_id' => $teamId, 'frequency' => $request->string('frequency')->toString(), 'enabled' => $request->boolean('enabled', true), @@ -275,8 +275,12 @@ private function persistSchedule( 'retention_amount_s3' => $request->integer('retention_amount_s3', 7), 'retention_days_s3' => $request->integer('retention_days_s3'), 'retention_max_storage_s3' => $request->float('retention_max_storage_s3'), - 'timeout' => $request->integer('timeout', 3600), - ]); + ]; + if ($request->has('timeout')) { + $attributes['timeout'] = $request->integer('timeout'); + } + + $backup = $storage->scheduledBackups()->updateOrCreate([], $attributes); $created = $backup->wasRecentlyCreated; auditLog('api.volume_backup.schedule_set', [ diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index e521093d7..c9a554e2a 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -15,6 +15,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; +use Visus\Cuid2\Cuid2; class Gitlab extends Controller { diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 3047bcc01..1e8450c1b 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -52,6 +52,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json'; + private const CONTAINER_REMOVE_TIMEOUT_MARKER = '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'; + private const DOCKER_CLIENT_ENV_KEYS = [ 'BUILDKIT_HOST', 'BUILDX_BUILDER', @@ -3977,15 +3979,45 @@ private function graceful_shutdown_container(string $containerName, bool $skipRe ); } else { $this->execute_remote_command( - [dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true], - ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] + [dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true] ); + $this->removeContainerWithTimeout($containerName); } } catch (Exception $error) { $this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr'); } } + private function removeContainerWithTimeout(string $containerName): void + { + $outputKey = 'container_remove_'.md5($containerName); + + $this->execute_remote_command([ + dockerRemoveCommandWithTimeout($containerName), + 'hidden' => true, + 'ignore_errors' => true, + 'save' => $outputKey, + 'append' => false, + ]); + + if (! isset($this->saved_outputs)) { + return; + } + + $output = (string) $this->saved_outputs->get($outputKey, ''); + if (! str_contains($output, self::CONTAINER_REMOVE_TIMEOUT_MARKER)) { + return; + } + + $this->application_deployment_queue->addLogEntry( + "Warning: Removing container {$containerName} timed out after 60 seconds. The deployment will continue and cleanup will be retried in 5 minutes.", + 'stderr' + ); + + RemoveContainerJob::dispatch($this->server->id, $containerName) + ->delay(now()->addMinutes(5)); + } + private function stop_running_container(bool $force = false) { try { @@ -5016,9 +5048,7 @@ public function failed(Throwable $exception): void // do not remove already running container for PR deployments } else { $this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr'); - $this->execute_remote_command( - ["docker rm -f $this->container_name >/dev/null 2>&1", 'hidden' => true, 'ignore_errors' => true] - ); + $this->removeContainerWithTimeout($this->container_name); } } } diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php index 91869eb12..054a739bc 100644 --- a/app/Jobs/CheckTraefikVersionForServerJob.php +++ b/app/Jobs/CheckTraefikVersionForServerJob.php @@ -33,10 +33,11 @@ public function __construct( */ public function handle(): void { + $this->clearOutdatedInfo(); + // Detect current version (makes SSH call) $currentVersion = getTraefikVersionFromDockerCompose($this->server); - // Update detected version in database $this->server->update(['detected_traefik_version' => $currentVersion]); if (! $currentVersion) { @@ -113,6 +114,11 @@ public function handle(): void ProxyStatusChangedUI::dispatch($this->server->team_id); } + private function clearOutdatedInfo(): void + { + $this->server->update(['traefik_outdated_info' => null]); + } + /** * Get information about newer branches if available. */ diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 82e35b73c..1838feb9e 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -279,33 +279,10 @@ public function handle(): void } else { return; } - } else { - if (str($databaseType)->contains('postgres')) { - // Format: db1,db2,db3 - $databasesToBackup = explode(',', $databasesToBackup); - $databasesToBackup = array_map('trim', $databasesToBackup); - } elseif (str($databaseType)->contains('mongo')) { - // Format: db1:collection1,collection2|db2:collection3,collection4 - // Only explode if it's a string, not if it's already an array - if (is_string($databasesToBackup)) { - $databasesToBackup = explode('|', $databasesToBackup); - $databasesToBackup = array_map('trim', $databasesToBackup); - } - } elseif (str($databaseType)->contains('mysql')) { - // Format: db1,db2,db3 - $databasesToBackup = explode(',', $databasesToBackup); - $databasesToBackup = array_map('trim', $databasesToBackup); - } elseif (str($databaseType)->contains('mariadb')) { - // Format: db1,db2,db3 - $databasesToBackup = explode(',', $databasesToBackup); - $databasesToBackup = array_map('trim', $databasesToBackup); - } elseif ($this->database instanceof StandaloneClickhouse) { - // Format: db1,db2,db3 - $databasesToBackup = explode(',', $databasesToBackup); - $databasesToBackup = array_map('trim', $databasesToBackup); - } else { - return; - } + } + $databasesToBackup = $this->databasesToBackup($databaseType, $databasesToBackup); + if ($databasesToBackup === []) { + return; } $this->backup_dir = backup_dir().'/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name; if ($this->database->name === 'coolify-db') { @@ -600,6 +577,30 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi } } + /** @return array */ + private function databasesToBackup(string $databaseType, string|array $databases): array + { + $type = str($databaseType); + + if ($this->backup->dump_all && $type->contains(['postgres', 'mysql', 'mariadb'])) { + return ['all']; + } + + if (is_array($databases)) { + return $databases; + } + + if ($type->contains('mongo')) { + return array_map('trim', explode('|', $databases)); + } + + if ($type->contains(['postgres', 'mysql', 'mariadb', 'clickhouse'])) { + return array_map('trim', explode(',', $databases)); + } + + return []; + } + private function backup_standalone_postgresql(string $database): void { try { diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index 436bf788b..124cc16cc 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -158,12 +158,15 @@ private function deleteApplicationPreview() ]) ->get(); + $cancelledDeployments = 0; + foreach ($activeDeployments as $activeDeployment) { try { // Mark deployment as cancelled $activeDeployment->update([ 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); + $cancelledDeployments++; // Add cancellation log entry $activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr'); @@ -186,6 +189,14 @@ private function deleteApplicationPreview() } } + if ($cancelledDeployments > 0) { + try { + next_after_cancel($server); + } catch (\Throwable $e) { + \Log::warning("Failed to advance deployment queue after deleting preview {$this->resource->id}: {$e->getMessage()}"); + } + } + try { if ($server->isSwarm()) { $escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}"); diff --git a/app/Jobs/RemoveContainerJob.php b/app/Jobs/RemoveContainerJob.php new file mode 100644 index 000000000..de2160324 --- /dev/null +++ b/app/Jobs/RemoveContainerJob.php @@ -0,0 +1,49 @@ +serverId); + + instant_remote_process( + [dockerRemoveCommandWithTimeout($this->containerName)], + $server, + timeout: 75, + disableMultiplexing: true, + ); + } + + public function backoff(): array + { + return [300, 900]; + } + + public function failed(?\Throwable $exception): void + { + Log::warning('Deferred container removal failed', [ + 'server_id' => $this->serverId, + 'container' => $this->containerName, + 'error' => $exception?->getMessage(), + ]); + } +} diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index dc11ec89e..f7bd5f933 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -25,6 +25,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public const MAX_OUTPUT_SIZE_BYTES = 5 * 1024 * 1024; + /** * The number of times the job may be attempted. */ @@ -148,10 +150,12 @@ public function handle(): void foreach ($this->containers as $containerName) { if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; - $exec = "docker exec {$containerName} {$cmd}"; + $dockerCommand = $this->server->isNonRoot() ? 'sudo docker' : 'docker'; + $execCommand = "{$dockerCommand} exec {$containerName} {$cmd}"; + $exec = $this->boundedTaskCommand($execCommand); // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently // See: https://github.com/coollabsio/coolify/issues/6736 - $this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->task_output = instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->timeout, disableMultiplexing: true); $this->task_log->update([ 'status' => 'success', 'message' => $this->task_output, @@ -204,6 +208,14 @@ public function handle(): void } } + private function boundedTaskCommand(string $command): string + { + $maxOutputBytes = self::MAX_OUTPUT_SIZE_BYTES; + $readLimit = $maxOutputBytes + 1; + + return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$readLimit} > \"\$output_file\"; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$(wc -c < \"\$output_file\")\" -gt {$maxOutputBytes} ]; then truncate -s {$maxOutputBytes} \"\$output_file\"; printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code"; + } + /** * Calculate the number of seconds to wait before retrying the job. */ diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index 39998a1f6..b567a71b7 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -28,14 +28,14 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue public int $maxExceptions = 1; - public int $timeout = 3600; + public int $timeout = ScheduledVolumeBackup::DEFAULT_TIMEOUT; private ?ScheduledVolumeBackupExecution $execution = null; public function __construct(public ScheduledVolumeBackup $backup) { $this->onQueue(crons_queue()); - $this->timeout = $backup->timeout ?? 3600; + $this->timeout = $backup->timeout ?? ScheduledVolumeBackup::DEFAULT_TIMEOUT; } public function middleware(): array diff --git a/app/Livewire/ActivityMonitor.php b/app/Livewire/ActivityMonitor.php index 665d14ba0..25935d88e 100644 --- a/app/Livewire/ActivityMonitor.php +++ b/app/Livewire/ActivityMonitor.php @@ -29,7 +29,10 @@ class ActivityMonitor extends Component public static $eventDispatched = false; - protected $listeners = ['activityMonitor' => 'newMonitorActivity']; + protected $listeners = [ + 'activityMonitor' => 'newMonitorActivity', + 'processDialogClosed' => 'clearActivity', + ]; public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null, $header = null) { @@ -50,6 +53,16 @@ public function newMonitorActivity($activityId, $eventToDispatch = 'activityFini $this->isPollingActive = true; } + public function clearActivity(): void + { + $this->activityId = null; + $this->activity = null; + $this->isPollingActive = false; + $this->eventToDispatch = 'activityFinished'; + $this->eventData = null; + self::$eventDispatched = false; + } + public function hydrateActivity() { if ($this->activityId === null) { diff --git a/app/Livewire/DeploymentsIndicator.php b/app/Livewire/DeploymentsIndicator.php index 235071dbe..28c9a00c6 100644 --- a/app/Livewire/DeploymentsIndicator.php +++ b/app/Livewire/DeploymentsIndicator.php @@ -54,12 +54,6 @@ public function deploymentCount() return $this->deployments->count(); } - #[Computed] - public function shouldReduceOpacity(): bool - { - return request()->routeIs('project.application.deployment.*'); - } - public function toggleExpanded() { $this->expanded = ! $this->expanded; diff --git a/app/Livewire/Project/Application/Backup/Create.php b/app/Livewire/Project/Application/Backup/Create.php index 68115e775..f26d81b88 100644 --- a/app/Livewire/Project/Application/Backup/Create.php +++ b/app/Livewire/Project/Application/Backup/Create.php @@ -82,7 +82,7 @@ public function mount(): void 'type' => 'Directory', 'name' => $directory->fs_path, ]); - $this->targets = $volumes->concat($directories)->values(); + $this->targets = collect($volumes->concat($directories)->all())->values(); $this->targetKey = $this->selectedTargetKey ?? data_get($this->targets->first(), 'key'); $this->loadSelectedBackup(); } diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 9ddd5e740..45a76a4c3 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -6,6 +6,7 @@ use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Application; use App\Models\Server; +use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; @@ -22,6 +23,8 @@ class Domains extends Component public string $redirect = 'both'; + public bool $isForceHttpsEnabled = true; + /** * Per compose-service www/non-www redirect direction. * Keys are wire-safe (dots encoded) — use serviceRedirectWireKey(). @@ -35,12 +38,20 @@ class Domains extends Component public string $newDomain = ''; + public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $newDomainPartsChanged = false; + public ?string $newDomainService = null; public ?int $editingIndex = null; public string $editingDomain = ''; + public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $editingDomainPartsChanged = false; + public ?string $editingService = null; /** @var array */ @@ -100,6 +111,7 @@ protected function rules(): array 'newDomain' => ValidationPatterns::applicationDomainRules(), 'editingDomain' => ValidationPatterns::applicationDomainRules(), 'redirect' => 'string|required|in:both,www,non-www', + 'isForceHttpsEnabled' => 'boolean', 'serviceRedirects' => 'array', 'serviceRedirects.*' => 'string|in:both,www,non-www', ]; @@ -151,6 +163,18 @@ public function updateRedirect(string $redirect): void $this->setRedirect(); } + public function updateForceHttps(): void + { + $this->authorize('update', $this->application); + $this->validateOnly('isForceHttpsEnabled'); + + $this->application->settings->is_force_https_enabled = $this->isForceHttpsEnabled; + $this->application->settings->save(); + $this->resetDefaultLabels(); + $this->dispatch('configurationChanged')->to(ConfigurationChecker::class); + $this->dispatch('success', 'HTTP to HTTPS redirect updated.'); + } + public function loadDomainState(): void { $this->application->refresh(); @@ -159,6 +183,7 @@ public function loadDomainState(): void $this->isCompose = $this->application->build_pack === 'dockercompose'; $this->labelsAreWritable = $this->application->settings->is_container_label_readonly_enabled === false; $this->redirect = $this->application->redirect ?? 'both'; + $this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled(); $settings = instanceSettings(); $this->dnsValidationEnabled = (bool) data_get($settings, 'is_dns_validation_enabled', true); @@ -662,6 +687,12 @@ public function updatedNewDomain(): void $this->resetAddDomainDnsGate(); } + public function updatedNewDomainParts(): void + { + $this->newDomainPartsChanged = true; + $this->resetAddDomainDnsGate(); + } + public function updatedNewDomainService(): void { $this->resetAddDomainDnsGate(); @@ -677,6 +708,8 @@ public function resetAddDomainDnsGate(): void public function resetAddDomainForm(): void { $this->newDomain = ''; + $this->newDomainParts = DomainUrlParts::empty(); + $this->newDomainPartsChanged = false; $this->resetAddDomainDnsGate(); $this->resetErrorBag('newDomain'); } @@ -743,6 +776,9 @@ public function addDomain(): void return; } + if ($this->newDomainPartsChanged) { + $this->newDomain = DomainUrlParts::compose(...$this->newDomainParts); + } $this->validateOnly('newDomain'); $normalized = ValidationPatterns::normalizeApplicationDomains($this->newDomain); @@ -893,6 +929,12 @@ public function updatedEditingDomain(): void $this->resetEditDomainDnsGate(); } + public function updatedEditingDomainParts(): void + { + $this->editingDomainPartsChanged = true; + $this->resetEditDomainDnsGate(); + } + public function resetEditDomainDnsGate(): void { $this->editDomainDnsFailed = false; @@ -908,10 +950,13 @@ public function startEdit(int $index): void $this->editingIndex = $index; $this->editingDomain = $this->domainRows[$index]['url']; + $this->editingDomainParts = DomainUrlParts::split($this->editingDomain); + $this->editingDomainPartsChanged = false; $this->editingService = $this->domainRows[$index]['service']; $this->resetEditDomainDnsGate(); $this->resetErrorBag('editingDomain'); $this->showEditDomainModal = true; + $this->dispatch('open-edit-domain'); } public function addSuggestedDomain(int $index): void @@ -990,6 +1035,8 @@ public function cancelEdit(): void $this->showEditDomainModal = false; $this->editingIndex = null; $this->editingDomain = ''; + $this->editingDomainParts = DomainUrlParts::empty(); + $this->editingDomainPartsChanged = false; $this->editingService = null; $this->resetEditDomainDnsGate(); $this->resetErrorBag('editingDomain'); @@ -1021,6 +1068,9 @@ public function updateDomain(): void return; } + if ($this->editingDomainPartsChanged) { + $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); + } $this->validateOnly('editingDomain'); $normalized = ValidationPatterns::normalizeApplicationDomains($this->editingDomain); diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 41fb1681b..73877a945 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -98,26 +98,34 @@ public function deleteBackup($executionId, $password, $selectedActions = []) return; } - $server = $execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class - ? $execution->scheduledDatabaseBackup->database->service->destination->server - : $execution->scheduledDatabaseBackup->database->destination->server; - try { - if ($execution->filename) { - deleteBackupsLocally($execution->filename, $server); + $deleteFromS3 = in_array('delete_backup_s3', $selectedActions, true); - if ($this->delete_backup_s3 && $execution->scheduledDatabaseBackup->s3) { - deleteBackupsS3($execution->filename, $execution->scheduledDatabaseBackup->s3); + if ($execution->filename && ! $execution->local_storage_deleted) { + $server = $this->backup->server(); + if (! $server) { + throw new \RuntimeException('The backup server is unavailable.'); } + + deleteBackupsLocally($execution->filename, $server, throwError: true); + } + + if ($deleteFromS3 && $execution->s3_uploaded && ! $execution->s3_storage_deleted) { + if (! $execution->scheduledDatabaseBackup->s3) { + throw new \RuntimeException('The S3 storage is unavailable.'); + } + + deleteBackupsS3($execution->filename, $execution->scheduledDatabaseBackup->s3); } $execution->delete(); + $this->delete_backup_s3 = false; $this->dispatch('success', 'Backup deleted.'); $this->refreshBackupExecutions(); } catch (\Exception $e) { $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); - return true; + return false; } return true; diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 8993cc251..051fb515d 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -209,11 +209,15 @@ public function instantSaveAdvanced() } } - public function instantSave() + public function instantSave(?bool $isPublic = null) { try { $this->authorize('update', $this->database); + if ($isPublic !== null) { + $this->isPublic = $isPublic; + } + if ($this->isPublic && ! $this->publicPort) { $this->dispatch('error', 'Public port is required.'); $this->isPublic = false; diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 925f8d969..cef0cf6ac 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -134,8 +134,9 @@ public function loadRepositories(int $github_app_id): void public function loadBranches() { - $this->selected_repository_owner = $this->repositories->where('id', $this->selected_repository_id)->first()['owner']['login']; - $this->selected_repository_repo = $this->repositories->where('id', $this->selected_repository_id)->first()['name']; + $repository = $this->repositories->firstWhere('id', $this->selected_repository_id); + $this->selected_repository_owner = data_get($repository, 'owner.login'); + $this->selected_repository_repo = data_get($repository, 'name'); $this->branches = collect(); $this->page = 1; $this->loadBranchByPage(); @@ -146,7 +147,10 @@ public function loadBranches() } } $this->branches = sortBranchesByPriority($this->branches); - $this->selected_branch_name = data_get($this->branches, '0.name', 'main'); + $defaultBranch = data_get($repository, 'default_branch', 'main'); + $this->selected_branch_name = $this->branches->contains('name', $defaultBranch) + ? $defaultBranch + : data_get($this->branches, '0.name', 'main'); } protected function loadBranchByPage() diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index a5479ca06..4690335d8 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -33,6 +33,9 @@ class Domains extends Component */ public array $serviceRedirects = []; + /** @var array */ + public array $forceHttpsRedirects = []; + /** Service application id when a pending domain conflict belongs to setServiceRedirect. */ public ?int $pendingRedirectServiceApplicationId = null; @@ -43,10 +46,18 @@ class Domains extends Component public string $newDomain = ''; + public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $newDomainPartsChanged = false; + public ?int $editingIndex = null; public string $editingDomain = ''; + public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $editingDomainPartsChanged = false; + public ?int $editingServiceApplicationId = null; public bool $showEditDomainModal = false; @@ -102,6 +113,8 @@ protected function rules(): array 'newServiceApplicationId' => 'nullable|integer', 'serviceRedirects' => 'array', 'serviceRedirects.*' => 'string|in:both,www,non-www', + 'forceHttpsRedirects' => 'array', + 'forceHttpsRedirects.*' => 'boolean', ]; } @@ -135,6 +148,22 @@ public function toggleNoindexDomain(int $serviceApplicationId, string $domain, s $this->dispatch('success', 'Search engine indexing updated.'); } + public function updateForceHttps(int $serviceApplicationId, bool $enabled): void + { + $application = $this->service->applications()->findOrFail($serviceApplicationId); + $this->authorize('update', $application); + + $this->forceHttpsRedirects[$serviceApplicationId] = $enabled; + $this->validateOnly("forceHttpsRedirects.{$serviceApplicationId}"); + + $application->is_force_https_enabled = $enabled; + $application->save(); + $this->service->parse(); + $this->refreshDomains(); + $this->dispatch('configurationChanged')->to(ConfigurationChecker::class); + $this->dispatch('success', 'HTTP to HTTPS redirect updated.'); + } + public function loadDomainState(): void { $this->service->loadMissing(['applications', 'server']); @@ -159,6 +188,10 @@ public function loadDomainState(): void $this->serverIpConfigured = null; } + $this->forceHttpsRedirects = $this->service->applications + ->mapWithKeys(fn (ServiceApplication $app) => [$app->id => $app->isForceHttpsEnabled()]) + ->all(); + $this->serviceApps = $this->service->applications ->sortBy(fn (ServiceApplication $app) => strtolower($app->human_name ?: $app->name)) ->values() @@ -509,6 +542,17 @@ protected function pruneDomainDnsStatusesToCurrentDomains(): void } public function updatedNewDomain(): void + { + $this->resetAddDomainDnsGate(); + } + + public function updatedNewDomainParts(): void + { + $this->newDomainPartsChanged = true; + $this->resetAddDomainDnsGate(); + } + + public function resetAddDomainDnsGate(): void { $this->addDomainDnsFailed = false; $this->addDomainDnsMessage = ''; @@ -522,6 +566,12 @@ public function updatedEditingDomain(): void $this->forceSaveEditDns = false; } + public function updatedEditingDomainParts(): void + { + $this->editingDomainPartsChanged = true; + $this->updatedEditingDomain(); + } + public function confirmAddDomainDespiteDns(): void { $this->forceSaveDns = true; @@ -842,6 +892,9 @@ public function addDomain(): void { try { $this->authorize('update', $this->service); + if ($this->newDomainPartsChanged) { + $this->newDomain = DomainUrlParts::compose(...$this->newDomainParts); + } $this->validateOnly('newDomain'); $app = $this->findServiceApp($this->newServiceApplicationId); @@ -893,6 +946,8 @@ public function addDomain(): void } $this->newDomain = ''; + $this->newDomainParts = DomainUrlParts::empty(); + $this->newDomainPartsChanged = false; $this->addDomainDnsFailed = false; $this->addDomainDnsMessage = ''; $this->forceSaveDns = false; @@ -916,12 +971,15 @@ public function startEdit(int $index): void $this->editingIndex = $index; $this->editingDomain = $this->domainRows[$index]['url']; + $this->editingDomainParts = DomainUrlParts::split($this->editingDomain); + $this->editingDomainPartsChanged = false; $this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id']; $this->editDomainDnsFailed = false; $this->editDomainDnsMessage = ''; $this->forceSaveEditDns = false; $this->resetErrorBag('editingDomain'); $this->showEditDomainModal = true; + $this->dispatch('open-edit-domain'); } public function cancelEdit(): void @@ -929,6 +987,8 @@ public function cancelEdit(): void $this->showEditDomainModal = false; $this->editingIndex = null; $this->editingDomain = ''; + $this->editingDomainParts = DomainUrlParts::empty(); + $this->editingDomainPartsChanged = false; $this->editingServiceApplicationId = null; $this->editDomainDnsFailed = false; $this->editDomainDnsMessage = ''; @@ -945,6 +1005,9 @@ public function updateDomain(): void return; } + if ($this->editingDomainPartsChanged) { + $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); + } $this->validateOnly('editingDomain'); $app = $this->findServiceApp($this->editingServiceApplicationId); @@ -1130,6 +1193,8 @@ public function generateDomain(): void } $this->newDomain = $domain; + $this->newDomainParts = DomainUrlParts::split($domain); + $this->newDomainPartsChanged = true; $this->updatedNewDomain(); } catch (\Throwable $e) { handleError($e, $this); diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index d0121bdc5..67a040ef7 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -25,6 +25,8 @@ class GetLogs extends Component { public const MAX_LOG_LINES = 50000; + public const MAX_DISPLAY_SIZE_BYTES = 5 * 1024 * 1024; + public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB public string $outputs = ''; @@ -154,14 +156,12 @@ public function getLogs($refresh = false) $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } else { $command = "docker logs -n {$this->numberOfLines} -t {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } } else { if ($this->server->isSwarm()) { @@ -170,22 +170,39 @@ public function getLogs($refresh = false) $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } else { $command = "docker logs -n {$this->numberOfLines} {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } } + $command = $this->boundedLogCommand($command, self::MAX_DISPLAY_SIZE_BYTES); + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); + // Collect new logs into temporary variable first to prevent flickering // (avoids clearing output before new data is ready) // Use array accumulation + implode for O(n) instead of O(n²) string concatenation $logChunks = []; - Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) { + $accumulatedBytes = 0; + $truncated = false; + Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) { + if ($truncated) { + return; + } + + $remainingBytes = self::MAX_DISPLAY_SIZE_BYTES - $accumulatedBytes; + $outputBytes = strlen($output); + if ($outputBytes > $remainingBytes) { + $logChunks[] = removeAnsiColors(substr($output, 0, max(0, $remainingBytes))); + $truncated = true; + + return; + } + $logChunks[] = removeAnsiColors($output); + $accumulatedBytes += $outputBytes; }); $newOutputs = implode('', $logChunks); @@ -198,6 +215,10 @@ public function getLogs($refresh = false) })->join("\n"); } + if ($truncated) { + $newOutputs .= "\n\n[... Output truncated at 5MB limit ...]"; + } + // Only update outputs after new data is ready (atomic update prevents flicker) $this->outputs = $newOutputs; } @@ -239,6 +260,7 @@ public function downloadAllLogs(): string $command = $command[0]; } + $command = $this->boundedLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES); $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); // Use array accumulation + implode for O(n) instead of O(n²) string concatenation @@ -252,20 +274,19 @@ public function downloadAllLogs(): string return; } - $output = removeAnsiColors($output); $outputBytes = strlen($output); if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) { $remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes; if ($remaining > 0) { - $logChunks[] = substr($output, 0, $remaining); + $logChunks[] = removeAnsiColors(substr($output, 0, $remaining)); } $truncated = true; return; } - $logChunks[] = $output; + $logChunks[] = removeAnsiColors($output); $accumulatedBytes += $outputBytes; }); @@ -287,6 +308,11 @@ public function downloadAllLogs(): string return sanitizeLogsForExport($allLogs); } + private function boundedLogCommand(string $command, int $maxBytes): string + { + return "({$command}) 2>&1 | head -c ".($maxBytes + 1); + } + public function render() { return view('livewire.project.shared.get-logs'); diff --git a/app/Livewire/Project/Shared/ScheduledTask/Executions.php b/app/Livewire/Project/Shared/ScheduledTask/Executions.php index ca2bbd9b4..e95fd2f5a 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Executions.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Executions.php @@ -10,6 +10,7 @@ class Executions extends Component { + #[Locked] public ScheduledTask $task; #[Locked] @@ -28,6 +29,7 @@ class Executions extends Component public $logsPerPage = 100; + #[Locked] public $selectedExecution = null; public $isPollingActive = false; @@ -45,7 +47,7 @@ public function mount($taskId) { try { $this->taskId = $taskId; - $this->task = ScheduledTask::findOrFail($taskId); + $this->task = ScheduledTask::where('team_id', Auth::user()->currentTeam()->id)->findOrFail($taskId); $this->executions = $this->task->executions()->take(20)->get(); $this->serverTimezone = data_get($this->task, 'application.destination.server.settings.server_timezone'); if (! $this->serverTimezone) { diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 882737f09..11df00153 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -15,8 +15,10 @@ class Show extends Component { use AuthorizesRequests; + #[Locked] public Application|Service $resource; + #[Locked] public ScheduledTask $task; #[Locked] @@ -115,6 +117,7 @@ public function toggleEnabled() { try { $this->authorize('update', $this->resource); + $this->authorize('update', $this->task); $this->isEnabled = ! $this->isEnabled; $this->task->enabled = $this->isEnabled; $this->task->save(); @@ -128,6 +131,7 @@ public function instantSave() { try { $this->authorize('update', $this->resource); + $this->authorize('update', $this->task); $this->syncData(true); $this->dispatch('success', 'Scheduled task updated.'); $this->refreshTasks(); @@ -140,6 +144,7 @@ public function submit() { try { $this->authorize('update', $this->resource); + $this->authorize('update', $this->task); $this->syncData(true); $this->dispatch('success', 'Scheduled task updated.'); } catch (\Exception $e) { @@ -160,6 +165,7 @@ public function delete() { try { $this->authorize('update', $this->resource); + $this->authorize('delete', $this->task); $this->task->delete(); if ($this->type === 'application') { @@ -176,6 +182,7 @@ public function executeNow() { try { $this->authorize('update', $this->resource); + $this->authorize('update', $this->task); ScheduledTaskJob::dispatch($this->task); $this->dispatch('success', 'Scheduled task executed.'); } catch (\Exception $e) { diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index a03820b4b..a10eb5ad0 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -56,7 +56,7 @@ class VolumeBackups extends Component public string $timezone = ''; - public int $timeout = 3600; + public int $timeout = ScheduledVolumeBackup::DEFAULT_TIMEOUT; public int $perPage = 10; diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index 31a857865..d9f70ea25 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -163,6 +163,7 @@ public function showNotification($event = null) $previousStatus = $this->proxyStatus; $this->server->refresh(); $this->proxyStatus = $this->server->proxy->status ?? 'unknown'; + $this->dispatchProxyConfigurationState(); // If event contains activityId, open activity monitor if ($event && isset($event['activityId'])) { @@ -227,6 +228,16 @@ public function refreshServer() { $this->server->refresh(); $this->server->load('settings'); + $this->dispatchProxyConfigurationState(); + } + + private function dispatchProxyConfigurationState(): void + { + $this->dispatch( + 'proxy-configuration-state-changed', + pending: $this->server->hasPendingProxyConfiguration(), + traefikOutdated: $this->server->hasCurrentTraefikOutdatedInfo(), + ); } public function refreshSentinelStatus($event = null): void @@ -248,10 +259,12 @@ public function getHasTraefikOutdatedProperty(): bool return false; } - // Check if server has outdated info stored - $outdatedInfo = $this->server->traefik_outdated_info; + return $this->server->hasCurrentTraefikOutdatedInfo(); + } - return ! empty($outdatedInfo) && isset($outdatedInfo['type']); + public function getHasPendingProxyConfigurationProperty(): bool + { + return $this->server->hasPendingProxyConfiguration(); } public function render() diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 8cd4e9640..811a01eb1 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -161,6 +161,7 @@ public function submit() $this->server->proxy->redirect_url = $this->redirectUrl; $this->server->save(); $this->server->setupDefaultRedirect(); + $this->dispatch('refreshServerShow'); $this->dispatch('success', 'Proxy configuration saved.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -175,6 +176,7 @@ public function resetProxyConfiguration() $this->proxySettings = GetProxyConfiguration::run($this->server, forceRegenerate: true); SaveProxyConfiguration::run($this->server, $this->proxySettings); $this->server->save(); + $this->dispatch('refreshServerShow'); $this->dispatch('success', 'Proxy configuration reset to default.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -276,7 +278,9 @@ public function getNewerTraefikBranchAvailableProperty(): ?string // Check if we have outdated info stored for this server (faster than computing) $outdatedInfo = $this->server->traefik_outdated_info; - if ($outdatedInfo && isset($outdatedInfo['type']) && $outdatedInfo['type'] === 'minor_upgrade') { + $storedCurrentVersion = ltrim((string) data_get($outdatedInfo, 'current'), 'v'); + $detectedCurrentVersion = ltrim($currentVersion, 'v'); + if ($storedCurrentVersion === $detectedCurrentVersion && data_get($outdatedInfo, 'type') === 'minor_upgrade') { // Use the upgrade_target field if available (e.g., "v3.6") if (isset($outdatedInfo['upgrade_target'])) { return str_starts_with($outdatedInfo['upgrade_target'], 'v') diff --git a/app/Livewire/Server/Proxy/DynamicConfigurations.php b/app/Livewire/Server/Proxy/DynamicConfigurations.php index f824645aa..6351dace8 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurations.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurations.php @@ -11,6 +11,12 @@ class DynamicConfigurations extends Component { use AuthorizesRequests; + public const MAX_CONFIGURATION_FILE_SIZE_BYTES = 1024 * 1024; + + public const MAX_TOTAL_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024; + + public const MAX_CONFIGURATION_FILES = 100; + public ?Server $server = null; public $parameters = []; @@ -44,15 +50,36 @@ public function loadDynamicConfigurations() return handleError($e, $this); } $proxy_path = $this->server->proxyPath(); - $files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic"], $this->server); + $fileLimit = self::MAX_CONFIGURATION_FILES + 1; + $files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic | head -n {$fileLimit}"], $this->server); $files = collect(explode("\n", $files))->filter(fn ($file) => ! empty($file)); $files = $files->map(fn ($file) => trim($file)); $files = $files->sort(); $contents = collect([]); - foreach ($files as $file) { + $skippedFiles = collect([]); + $totalBytes = 0; + if ($files->count() > self::MAX_CONFIGURATION_FILES) { + $skippedFiles->push('additional files'); + } + foreach ($files->take(self::MAX_CONFIGURATION_FILES) as $file) { $without_extension = str_replace('.', '|', $file); - $content = instant_remote_process(["cat {$proxy_path}/dynamic/{$file}"], $this->server); - $contents[$without_extension] = $content ?? ''; + $filePath = escapeshellarg("{$proxy_path}/dynamic/{$file}"); + $readLimit = self::MAX_CONFIGURATION_FILE_SIZE_BYTES + 1; + $content = instant_remote_process(["head -c {$readLimit} {$filePath}"], $this->server); + $content = $content ?? ''; + $contentBytes = strlen($content); + + if ($contentBytes > self::MAX_CONFIGURATION_FILE_SIZE_BYTES || $totalBytes + $contentBytes > self::MAX_TOTAL_CONFIGURATION_SIZE_BYTES) { + $skippedFiles->push($file); + + continue; + } + + $contents[$without_extension] = $content; + $totalBytes += $contentBytes; + } + if ($skippedFiles->isNotEmpty()) { + $this->dispatch('warning', 'Some dynamic configurations were not loaded because they exceed the safe display limits: '.$skippedFiles->implode(', ')); } $this->contents = $contents; $this->dispatch('$refresh'); diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index cd05002aa..a69eb3f80 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -146,7 +146,7 @@ public function submit() { try { $this->syncData(true); - $this->dispatch('success', 'Sentinel settings updated.'); + $this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 91bc03d21..40705617f 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -20,6 +20,9 @@ class Index extends Component #[Validate('nullable|string|max:255|url')] public ?string $fqdn = null; + #[Validate('boolean')] + public bool $is_dashboard_force_https_enabled = true; + #[Validate('required|integer|min:1025|max:65535')] public int $public_port_min; @@ -68,6 +71,7 @@ public function mount() $this->server = Server::findOrFail(0); } $this->fqdn = $this->settings->fqdn; + $this->is_dashboard_force_https_enabled = $this->settings->is_dashboard_force_https_enabled; $this->public_port_min = $this->settings->public_port_min; $this->public_port_max = $this->settings->public_port_max; $this->instance_name = $this->settings->instance_name; @@ -91,6 +95,7 @@ public function instantSave($isSave = true) $this->authorize('update', $this->settings); $this->validate(); $this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn; + $this->settings->is_dashboard_force_https_enabled = $this->is_dashboard_force_https_enabled; $this->settings->public_port_min = $this->public_port_min; $this->settings->public_port_max = $this->public_port_max; $this->settings->instance_name = $this->instance_name; diff --git a/app/Livewire/Storage/Create.php b/app/Livewire/Storage/Create.php index d741e6918..9e22e5491 100644 --- a/app/Livewire/Storage/Create.php +++ b/app/Livewire/Storage/Create.php @@ -5,6 +5,7 @@ use App\Models\S3Storage; use App\Rules\SafeWebhookUrl; use App\Rules\ValidS3BucketName; +use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Uri; @@ -28,6 +29,10 @@ class Create extends Component public string $endpoint = ''; + public array $endpointParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $endpointPartsChanged = false; + public S3Storage $storage; protected function rules(): array @@ -76,6 +81,9 @@ public function submit() try { $this->authorize('create', S3Storage::class); + if ($this->endpointPartsChanged) { + $this->endpoint = DomainUrlParts::compose(...$this->endpointParts); + } $this->endpoint = $this->normalizeEndpoint($this->endpoint); $this->validate(); $this->storage = new S3Storage; @@ -101,6 +109,11 @@ public function submit() } } + public function updatedEndpointParts(): void + { + $this->endpointPartsChanged = true; + } + private function connectionErrorDescription(\Throwable $exception): string { $settingsUrl = route('settings.advanced').'#endpoint-section'; diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index 94a0657ef..30084dcd3 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -5,6 +5,7 @@ use App\Models\S3Storage; use App\Rules\SafeWebhookUrl; use App\Rules\ValidS3BucketName; +use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; @@ -24,6 +25,10 @@ class Form extends Component public string $endpoint; + public array $endpointParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $endpointPartsChanged = false; + public string $bucket; public string $region; @@ -101,6 +106,8 @@ private function syncData(bool $toModel = false): void $this->name = $this->storage->name; $this->description = $this->storage->description; $this->endpoint = $this->storage->endpoint; + $this->endpointParts = DomainUrlParts::split($this->endpoint); + $this->endpointPartsChanged = false; $this->bucket = $this->storage->bucket; $this->region = $this->storage->region; $this->key = $this->storage->key; @@ -126,6 +133,9 @@ public function testConnection() try { $this->authorize('validateConnection', $this->storage); + if ($this->endpointPartsChanged) { + $this->endpoint = DomainUrlParts::compose(...$this->endpointParts); + } $testedStorage = new S3Storage; $testedStorage->uuid = $this->storage->uuid; $testedStorage->team_id = $this->storage->team_id; @@ -166,6 +176,9 @@ public function submit() { try { $this->authorize('update', $this->storage); + if ($this->endpointPartsChanged) { + $this->endpoint = DomainUrlParts::compose(...$this->endpointParts); + } DB::transaction(function () { $this->validate(); @@ -195,4 +208,9 @@ public function submit() return handleError($e, $this); } } + + public function updatedEndpointParts(): void + { + $this->endpointPartsChanged = true; + } } diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php index d429edb5f..280e26b9e 100644 --- a/app/Mcp/Concerns/BuildsResponse.php +++ b/app/Mcp/Concerns/BuildsResponse.php @@ -47,6 +47,18 @@ trait BuildsResponse // app/env secrets 'value', 'real_value', 'http_basic_auth_password', + // free-form commands / configurations can embed credentials + 'git_full_url', + 'install_command', 'build_command', 'start_command', + 'health_check_command', 'health_check_response_text', + 'custom_docker_run_options', 'pre_deployment_command', 'post_deployment_command', + 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', + 'custom_nginx_configuration', + + // raw database configuration blobs + 'postgres_conf', 'mysql_conf', 'mariadb_conf', 'mongo_conf', + 'redis_conf', 'keydb_conf', + // database connection strings embed credentials 'internal_db_url', 'external_db_url', 'init_scripts', @@ -58,6 +70,7 @@ trait BuildsResponse // bulky / unsafe blobs 'dockerfile', 'docker_compose', 'docker_compose_raw', + 'last_saved_proxy_configuration', 'custom_labels', 'environment_variables', 'environment_variables_preview', 'validation_logs', 'server_metadata', 'logs', 'configuration_snapshot', diff --git a/app/Mcp/Tools/CancelDeployment.php b/app/Mcp/Tools/CancelDeployment.php index bbec09126..1133f3429 100644 --- a/app/Mcp/Tools/CancelDeployment.php +++ b/app/Mcp/Tools/CancelDeployment.php @@ -104,6 +104,13 @@ public function handle(Request $request): Response 'server_id' => $deployment->server_id, ]); + try { + $deploymentServer = Server::whereTeamId($teamId)->find($deployment->server_id); + next_after_cancel($deploymentServer); + } catch (\Throwable $e) { + \Log::warning("Failed to advance deployment queue after cancelling deployment {$deployment->id}: {$e->getMessage()}"); + } + return $this->mcpSuccess($request, $this->respond([ 'ok' => true, 'message' => 'Deployment cancelled successfully.', diff --git a/app/Models/Application.php b/app/Models/Application.php index 2b203f4a9..fef76cd39 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -122,6 +122,8 @@ class Application extends BaseModel { use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; + public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; + private static $parserVersion = '5'; protected $fillable = [ @@ -2109,6 +2111,9 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = $workdir = rtrim($this->base_directory, '/'); $composeFile = $this->docker_compose_location; $fileList = collect([".$workdir$composeFile"]); + $composeFilePath = escapeshellarg(".$workdir$composeFile"); + $composeReadLimit = self::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1; + $readComposeFile = "if [ \"$(wc -c < {$composeFilePath})\" -gt ".self::MAX_DOCKER_COMPOSE_SIZE_BYTES." ]; then echo '__COOLIFY_COMPOSE_TOO_LARGE__'; else head -c {$composeReadLimit} {$composeFilePath}; fi"; $gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid); if (! $gitRemoteStatus['is_accessible']) { throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.'); @@ -2139,7 +2144,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = 'git sparse-checkout init', "git sparse-checkout set {$fileList->implode(' ')}", 'git read-tree -mu HEAD', - "cat .$workdir$composeFile", + $readComposeFile, ]); } else { $commands = collect([ @@ -2151,11 +2156,14 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = 'git sparse-checkout init --cone', "git sparse-checkout set {$fileList->implode(' ')}", 'git read-tree -mu HEAD', - "cat .$workdir$composeFile", + $readComposeFile, ]); } try { $composeFileContent = instant_remote_process($commands, $this->destination->server); + if ($composeFileContent === '__COOLIFY_COMPOSE_TOO_LARGE__' || strlen($composeFileContent) > self::MAX_DOCKER_COMPOSE_SIZE_BYTES) { + throw new RuntimeException('Docker Compose file exceeds the 5 MiB size limit.'); + } } catch (\Exception $e) { // Restore original values on failure only $this->docker_compose_location = $initialDockerComposeLocation; @@ -2171,6 +2179,9 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = } throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.'); } + if (str($e->getMessage())->contains('exceeds the 5 MiB size limit')) { + throw $e; + } throw new RuntimeException('Failed to read the Docker Compose file from the repository.'); } finally { // Cleanup only - restoration happens in catch block diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index bc8a5356e..35683fbf4 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -9,6 +9,10 @@ class InstanceSettings extends Model { + protected $attributes = [ + 'is_dashboard_force_https_enabled' => true, + ]; + protected $fillable = [ 'public_ipv4', 'public_ipv6', @@ -52,6 +56,7 @@ class InstanceSettings extends Model 'webhook_allow_localhost', 'avatar_storage_type', 'avatar_s3_storage_id', + 'is_dashboard_force_https_enabled', ]; protected $hidden = [ @@ -92,6 +97,7 @@ class InstanceSettings extends Model 'is_mcp_server_enabled' => 'boolean', 'webhook_allowed_internal_hosts' => 'array', 'webhook_allow_localhost' => 'boolean', + 'is_dashboard_force_https_enabled' => 'boolean', ]; protected static function booted(): void diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 86873d1a1..92b785340 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -138,9 +138,9 @@ public function loadStorageOnServer() return; } - $content = instant_remote_process(["cat {$escapedPath}"], $server, false); + $content = $this->readRemoteFileContent($escapedPath, $server); // Check if content contains binary data by looking for null bytes or non-printable characters - if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) { + if ($content !== self::TOO_LARGE_PLACEHOLDER && (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content))) { $content = self::BINARY_PLACEHOLDER; } $this->content = $content; @@ -161,6 +161,27 @@ protected function remoteFileExceedsLimit(string $escapedPath, $server): bool return $size > self::MAX_CONTENT_SIZE; } + /** + * Cap the remote read itself so a file that grows after the size check + * cannot be fully slurped into PHP memory. + */ + protected function readRemoteFileContent(string $escapedPath, $server): string + { + $readLimit = self::MAX_CONTENT_SIZE + 1; + $content = instant_remote_process(["head -c {$readLimit} {$escapedPath}"], $server, false); + + return self::contentFromBoundedRead($content); + } + + public static function contentFromBoundedRead(?string $content): string + { + if (strlen((string) $content) > self::MAX_CONTENT_SIZE) { + return self::TOO_LARGE_PLACEHOLDER; + } + + return (string) $content; + } + public function deleteStorageOnServer() { if ($this->is_host_file) { @@ -253,7 +274,7 @@ public function saveStorageOnServer() if ($this->remoteFileExceedsLimit($escapedPath, $server)) { $this->content = self::TOO_LARGE_PLACEHOLDER; } else { - $this->content = instant_remote_process(["cat {$escapedPath}"], $server, false); + $this->content = $this->readRemoteFileContent($escapedPath, $server); } $this->is_directory = false; $this->save(); diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index e8e1788e3..e4b1e2fd6 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -198,7 +198,7 @@ public function testConnection(bool $shouldSave = false) try { $mail = new MailMessage; $mail->subject('Coolify: S3 Storage Connection Error'); - $mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $exception->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]); + $mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $e->getMessage(), 'url' => base_url().'/storages/'.$this->uuid]); // Load the team with its members and their roles explicitly $team = $this->team()->with(['members' => function ($query) { diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index 4038c6288..e41c793c8 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -11,6 +11,7 @@ class ScheduledDatabaseBackup extends BaseModel protected function casts(): array { return [ + 'dump_all' => 'boolean', 'database_backup_retention_max_storage_locally' => 'float', 'database_backup_retention_max_storage_s3' => 'float', ]; diff --git a/app/Models/ScheduledVolumeBackup.php b/app/Models/ScheduledVolumeBackup.php index a33368142..7f33fd92b 100644 --- a/app/Models/ScheduledVolumeBackup.php +++ b/app/Models/ScheduledVolumeBackup.php @@ -11,6 +11,8 @@ class ScheduledVolumeBackup extends BaseModel { + public const int DEFAULT_TIMEOUT = 36000; + protected $fillable = [ 'uuid', 'backupable_type', diff --git a/app/Models/Server.php b/app/Models/Server.php index 1acc4e45f..f7a4bf20c 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -731,11 +731,12 @@ public function setupDynamicProxyConfiguration() ]; if ($schema === 'https') { - $traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = [ - 0 => 'redirect-to-https', - ]; + $traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = $this->dashboardHttpMiddlewares($settings); $traefik_dynamic_conf['http']['routers']['coolify-https'] = [ + 'middlewares' => [ + 0 => 'gzip', + ], 'entryPoints' => [ 0 => 'https', ], @@ -789,8 +790,10 @@ public function setupDynamicProxyConfiguration() $url = Url::fromString($settings->fqdn); $host = $url->getHost(); $schema = $url->getScheme(); + $siteAddress = $this->dashboardCaddySiteAddress($settings, $schema, $host); $caddy_file = " -$schema://$host { +$siteAddress { + encode zstd gzip handle /app/* { reverse_proxy coolify-realtime:6001 } @@ -815,6 +818,24 @@ public function reloadCaddy() ], $this); } + public function dashboardHttpMiddlewares(InstanceSettings $settings): array + { + if ($settings->is_dashboard_force_https_enabled) { + return ['redirect-to-https']; + } + + return ['gzip']; + } + + public function dashboardCaddySiteAddress(InstanceSettings $settings, string $schema, string $host): string + { + if ($schema === 'https' && ! $settings->is_dashboard_force_https_enabled) { + return "http://{$host}, https://{$host}"; + } + + return "{$schema}://{$host}"; + } + public function proxyPath() { $base_path = config('constants.coolify.base_config_path'); @@ -837,6 +858,33 @@ public function proxyType() return data_get($this->proxy, 'type'); } + public function hasPendingProxyConfiguration(): bool + { + if ($this->proxy->get('status') !== 'running') { + return false; + } + + $savedSettings = $this->proxy->get('last_saved_settings'); + $appliedSettings = $this->proxy->get('last_applied_settings'); + + return filled($savedSettings) && filled($appliedSettings) && $savedSettings !== $appliedSettings; + } + + public function hasCurrentTraefikOutdatedInfo(): bool + { + if ($this->proxyType() !== ProxyTypes::TRAEFIK->value) { + return false; + } + + $detectedVersion = ltrim((string) $this->detected_traefik_version, 'v'); + $storedVersion = ltrim((string) data_get($this->traefik_outdated_info, 'current'), 'v'); + $type = data_get($this->traefik_outdated_info, 'type'); + + return filled($detectedVersion) + && $storedVersion === $detectedVersion + && in_array($type, ['patch_update', 'minor_upgrade'], true); + } + public function scopeWithProxy(): Builder { return $this->proxy->modelScope(); diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 4afc6c29d..9763fa894 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -31,6 +31,7 @@ class ServiceApplication extends BaseModel 'is_include_timestamps', 'is_gzip_enabled', 'is_stripprefix_enabled', + 'is_force_https_enabled', 'last_online_at', 'is_migrated', ]; @@ -44,11 +45,16 @@ class ServiceApplication extends BaseModel 'domain_dns_statuses', ]; + protected $attributes = [ + 'is_force_https_enabled' => true, + ]; + protected function casts(): array { return [ 'domain_dns_statuses' => 'array', 'noindex_domains' => 'array', + 'is_force_https_enabled' => 'boolean', ]; } @@ -124,6 +130,11 @@ public function isGzipEnabled() return data_get($this, 'is_gzip_enabled', true); } + public function isForceHttpsEnabled(): bool + { + return $this->is_force_https_enabled; + } + public function type() { return 'service'; diff --git a/app/Policies/ScheduledTaskPolicy.php b/app/Policies/ScheduledTaskPolicy.php new file mode 100644 index 000000000..fac7e7b22 --- /dev/null +++ b/app/Policies/ScheduledTaskPolicy.php @@ -0,0 +1,70 @@ +teams->contains('id', $scheduledTask->team_id); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, ScheduledTask $scheduledTask): Response + { + if (! $user->isAdminOfTeam($scheduledTask->team_id)) { + return Response::deny('You need at least admin or owner permissions to update this scheduled task.'); + } + + return Response::allow(); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, ScheduledTask $scheduledTask): bool + { + return $user->isAdminOfTeam($scheduledTask->team_id); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, ScheduledTask $scheduledTask): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, ScheduledTask $scheduledTask): bool + { + return false; + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 008544bea..e8e6fb42c 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -20,6 +20,7 @@ use App\Models\Project; use App\Models\PushoverNotificationSettings; use App\Models\S3Storage; +use App\Models\ScheduledTask; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; @@ -58,6 +59,7 @@ use App\Policies\ProjectPolicy; use App\Policies\ResourceCreatePolicy; use App\Policies\S3StoragePolicy; +use App\Policies\ScheduledTaskPolicy; use App\Policies\ServerPolicy; use App\Policies\ServiceApplicationPolicy; use App\Policies\ServiceDatabasePolicy; @@ -120,6 +122,9 @@ class AuthServiceProvider extends ServiceProvider // S3 storage policy S3Storage::class => S3StoragePolicy::class, + // Scheduled task policy + ScheduledTask::class => ScheduledTaskPolicy::class, + // Team policy Team::class => TeamPolicy::class, diff --git a/app/Services/AvatarStorageService.php b/app/Services/AvatarStorageService.php index d1446ea37..983b144b9 100644 --- a/app/Services/AvatarStorageService.php +++ b/app/Services/AvatarStorageService.php @@ -71,7 +71,7 @@ public function delete(User $user): void protected function disk(string $storageType, ?int $s3StorageId): FilesystemAdapter { if ($storageType !== 's3') { - return Storage::disk('local'); + return Storage::disk('images'); } $storage = S3Storage::query()->whereKey($s3StorageId)->where('is_usable', true)->first(); diff --git a/app/View/Components/Services/Links.php b/app/View/Components/Services/Links.php index 147b49d68..5b77dc90f 100644 --- a/app/View/Components/Services/Links.php +++ b/app/View/Components/Services/Links.php @@ -12,7 +12,7 @@ class Links extends Component { public Collection $links; - public function __construct(public Service $service, public bool $fullWidth = false) + public function __construct(public Service $service, public bool $fullWidth = false, public bool $compact = false) { $this->links = collect([]); $service->applications()->get()->map(function ($application) { diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 3be2ae008..a60ba675b 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -263,6 +263,15 @@ function dockerStopCommand(int $timeout, string $containers, Server|string|null return $command; } + +function dockerRemoveCommandWithTimeout(string $container, int $timeout = 60, int $killAfter = 10): string +{ + $container = escapeShellValue($container); + $script = "if command -v timeout >/dev/null 2>&1; then timeout -k {$killAfter}s {$timeout}s docker rm -f {$container}; exit_code=\$?; else exit_code=124; fi; if [ \"\$exit_code\" -eq 124 ]; then echo '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'; fi; exit \$exit_code"; + + return 'bash -c '.escapeShellValue($script); +} + function escapeShellValue(string $value): string { return "'".str_replace("'", "'\\''", $value)."'"; @@ -518,6 +527,10 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, $path = $url->getPath(); $host_without_www = str($host)->replace('www.', ''); $schema = $url->getScheme(); + $siteAddress = "{$schema}://{$host}"; + if ($schema === 'https' && ! $is_force_https_enabled) { + $siteAddress = "http://{$host}, https://{$host}"; + } $port = $url->getPort(); $handle = 'handle_path'; if (! $is_stripprefix_enabled) { @@ -529,7 +542,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, if (is_null($port) && $predefinedPort) { $port = $predefinedPort; } - $labels->push("caddy_{$loop}={$schema}://{$host}"); + $labels->push("caddy_{$loop}={$siteAddress}"); if (isNoindexDomain($domain, $noindex_domains)) { // Caddy's header directive takes either inline arguments or a block, // never both, so -Server has to move into the block alongside it. @@ -549,11 +562,12 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, if ($is_gzip_enabled) { $labels->push("caddy_{$loop}.encode=zstd gzip"); } + $redirect_schema = $is_force_https_enabled ? $schema : '{scheme}'; if ($redirect_direction === 'www' && ! str($host)->startsWith('www.')) { - $labels->push("caddy_{$loop}.redir={$schema}://www.{$host}{uri}"); + $labels->push("caddy_{$loop}.redir={$redirect_schema}://www.{$host}{uri}"); } if ($redirect_direction === 'non-www' && str($host)->startsWith('www.')) { - $labels->push("caddy_{$loop}.redir={$schema}://{$host_without_www}{uri}"); + $labels->push("caddy_{$loop}.redir={$redirect_schema}://{$host_without_www}{uri}"); } if ($is_http_basic_auth_enabled) { $labels->push("caddy_{$loop}.basicauth.{$http_basic_auth_username}=\"{$hashedPassword}\""); @@ -563,7 +577,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, return $labels->sort(); } -function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null) +function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true) { $labels = collect([]); $labels->push('traefik.enable=true'); @@ -646,14 +660,15 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $to_www_name = "{$loop}-{$uuid}-to-www"; $to_non_www_name = "{$loop}-{$uuid}-to-non-www"; + $redirect_capture_prefix = $escape_redirect_replacement_for_compose ? '$$' : '$'; $redirect_to_non_www = [ "traefik.http.middlewares.{$to_non_www_name}.redirectregex.regex=^(http|https)://www\.(.+)", - "traefik.http.middlewares.{$to_non_www_name}.redirectregex.replacement=\$\${1}://\$\${2}", + "traefik.http.middlewares.{$to_non_www_name}.redirectregex.replacement={$redirect_capture_prefix}{1}://{$redirect_capture_prefix}{2}", "traefik.http.middlewares.{$to_non_www_name}.redirectregex.permanent=false", ]; $redirect_to_www = [ "traefik.http.middlewares.{$to_www_name}.redirectregex.regex=^(http|https)://(?:www\.)?(.+)", - "traefik.http.middlewares.{$to_www_name}.redirectregex.replacement=\$\${1}://www.\$\${2}", + "traefik.http.middlewares.{$to_www_name}.redirectregex.replacement={$redirect_capture_prefix}{1}://www.{$redirect_capture_prefix}{2}", "traefik.http.middlewares.{$to_www_name}.redirectregex.permanent=false", ]; if ($schema === 'https') { @@ -695,8 +710,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $middlewares->push($middleware_name); }); if ($middlewares->isNotEmpty()) { - $middlewares = $middlewares->join(','); - $labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares}"); + $labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares->join(',')}"); } } else { $middlewares = collect([]); @@ -724,8 +738,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $middlewares->push($middleware_name); }); if ($middlewares->isNotEmpty()) { - $middlewares = $middlewares->join(','); - $labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares}"); + $labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares->join(',')}"); } } $labels->push("traefik.http.routers.{$https_label}.tls=true"); @@ -738,15 +751,17 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $labels->push("traefik.http.services.{$http_label}.loadbalancer.server.port=$port"); $labels->push("traefik.http.routers.{$http_label}.service={$http_label}"); } - $middlewares = collect([]); - if ($is_noindex) { - $middlewares->push($noindex_name); - } if ($is_force_https_enabled) { - $middlewares->push('redirect-to-https'); + $httpMiddlewares = collect([]); + if ($is_noindex) { + $httpMiddlewares->push($noindex_name); + } + $httpMiddlewares->push('redirect-to-https'); + } else { + $httpMiddlewares = $middlewares; } - if ($middlewares->isNotEmpty()) { - $labels->push("traefik.http.routers.{$http_label}.middlewares={$middlewares->join(',')}"); + if ($httpMiddlewares->isNotEmpty()) { + $labels->push("traefik.http.routers.{$http_label}.middlewares={$httpMiddlewares->join(',')}"); } } else { // Set labels for http @@ -876,6 +891,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + escape_redirect_replacement_for_compose: false, )); break; } @@ -892,6 +908,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + escape_redirect_replacement_for_compose: false, )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, @@ -932,6 +949,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + escape_redirect_replacement_for_compose: false, )); break; case ProxyTypes::CADDY->value: @@ -962,6 +980,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + escape_redirect_replacement_for_compose: false, )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index ddfd45531..b47e57047 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -2645,7 +2645,7 @@ function serviceParser(Service $resource): Collection $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -2660,7 +2660,7 @@ function serviceParser(Service $resource): Collection network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -2676,7 +2676,7 @@ function serviceParser(Service $resource): Collection $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -2689,7 +2689,7 @@ function serviceParser(Service $resource): Collection network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index 699704393..31e55da33 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -107,8 +107,8 @@ function collectDockerNetworksByServer(Server $server) } function connectProxyToNetworks(Server $server) { - ['networks' => $networks] = collectDockerNetworksByServer($server); if ($server->isSwarm()) { + ['networks' => $networks] = collectDockerNetworksByServer($server); $commands = $networks->map(function ($network) { $safe = escapeshellarg($network); @@ -118,19 +118,20 @@ function connectProxyToNetworks(Server $server) "echo 'Successfully connected coolify-proxy to {$safe} network.'", ]; }); - } else { - $commands = $networks->map(function ($network) { - $safe = escapeshellarg($network); - return [ - "docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --attachable {$safe} >/dev/null", - "docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true", - "echo 'Successfully connected coolify-proxy to {$safe} network.'", - ]; - }); + return $commands->flatten(); } - return $commands->flatten(); + return collect([ + 'for network in $(docker inspect $(docker ps --filter label=coolify.managed=true --format "{{.ID}}") --format=\'{{range $network, $_ := .NetworkSettings.Networks}}{{println $network}}{{end}}\' 2>/dev/null | sort -u); do', + ' if [ -z "$network" ] || [ "$network" = "bridge" ] || [ "$network" = "host" ] || [ "$network" = "none" ] || [ "$network" = "default" ]; then', + ' continue', + ' fi', + ' if docker network inspect "$network" >/dev/null 2>&1; then', + ' docker network connect "$network" coolify-proxy >/dev/null 2>&1 || true', + ' fi', + 'done', + ]); } /** diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index d8986de3f..9cabe84f1 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -167,13 +167,11 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli $isDir = instant_remote_process(["test -d $fileLocation && echo OK || echo NOK"], $server); if ($isFile === 'OK') { - // If its a file & exists - $filesystemContent = instant_remote_process(["cat $fileLocation"], $server); - if ($fileVolume->is_based_on_git) { - $fileVolume->content = $filesystemContent; - } $fileVolume->is_directory = false; $fileVolume->save(); + if ($fileVolume->is_based_on_git) { + $fileVolume->loadStorageOnServer(); + } } elseif ($isDir === 'OK') { // If its a directory & exists $fileVolume->content = null; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index d3cc6d826..6d7c312b8 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3050,7 +3050,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $savedService->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), @@ -3065,7 +3065,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal network: $resource->destination->network, uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $savedService->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), @@ -3080,7 +3080,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $savedService->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), @@ -3093,7 +3093,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal network: $resource->destination->network, uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $savedService->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php index b8ef84687..397efc387 100644 --- a/bootstrap/helpers/sudo.php +++ b/bootstrap/helpers/sudo.php @@ -95,6 +95,7 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array $isComplexPipeCommand = ( $line->contains(' | sh') || $line->contains(' | bash') || + $line->contains(' sh -c ') || ($line->contains(' | ') && ($line->contains('||') || $line->contains('&&'))) ); diff --git a/config/constants.php b/config/constants.php index 1e6395df8..e0aefe8e2 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.6', + 'version' => env('COOLIFY_VERSION') ?: '4.3.9', 'helper_version' => '1.0.15', 'realtime_version' => '1.0.17', 'railpack_version' => '0.23.0', diff --git a/config/filesystems.php b/config/filesystems.php index ba0921a79..966cd0d5a 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -35,6 +35,13 @@ 'throw' => false, ], + 'images' => [ + 'driver' => 'local', + 'root' => storage_path('app/images'), + 'visibility' => 'private', + 'throw' => false, + ], + 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), diff --git a/config/horizon.php b/config/horizon.php index d86c52aff..fe35734c2 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -1,5 +1,6 @@ 1, 'nice' => 0, 'sleep' => 3, - 'timeout' => env('HORIZON_TIMEOUT', 36000), + 'timeout' => min( + max((int) env('HORIZON_TIMEOUT', 39600), ScheduledVolumeBackup::DEFAULT_TIMEOUT + 600), + 85800, + ), ], ], diff --git a/database/migrations/2026_08_15_000000_increase_default_volume_backup_timeout.php b/database/migrations/2026_08_15_000000_increase_default_volume_backup_timeout.php new file mode 100644 index 000000000..32eccce96 --- /dev/null +++ b/database/migrations/2026_08_15_000000_increase_default_volume_backup_timeout.php @@ -0,0 +1,22 @@ +unsignedInteger('timeout')->default(36000)->change(); + }); + } + + public function down(): void + { + Schema::table('scheduled_volume_backups', function (Blueprint $table) { + $table->unsignedInteger('timeout')->default(3600)->change(); + }); + } +}; diff --git a/database/migrations/2026_08_17_000000_add_is_force_https_enabled_to_service_applications_table.php b/database/migrations/2026_08_17_000000_add_is_force_https_enabled_to_service_applications_table.php new file mode 100644 index 000000000..f389ef27d --- /dev/null +++ b/database/migrations/2026_08_17_000000_add_is_force_https_enabled_to_service_applications_table.php @@ -0,0 +1,28 @@ +boolean('is_force_https_enabled')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('service_applications', function (Blueprint $table) { + $table->dropColumn('is_force_https_enabled'); + }); + } +}; diff --git a/database/migrations/2026_08_18_104130_add_is_dashboard_force_https_enabled_to_instance_settings_table.php b/database/migrations/2026_08_18_104130_add_is_dashboard_force_https_enabled_to_instance_settings_table.php new file mode 100644 index 000000000..d88f9be3f --- /dev/null +++ b/database/migrations/2026_08_18_104130_add_is_dashboard_force_https_enabled_to_instance_settings_table.php @@ -0,0 +1,28 @@ +boolean('is_dashboard_force_https_enabled')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('is_dashboard_force_https_enabled'); + }); + } +}; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9edc00e70..0d7caceb9 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -11,6 +11,7 @@ services: - /data/coolify/databases:/var/www/html/storage/app/databases - /data/coolify/services:/var/www/html/storage/app/services - /data/coolify/backups:/var/www/html/storage/app/backups + - /data/coolify/images:/var/www/html/storage/app/images environment: - APP_ENV=${APP_ENV:-production} - PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M} diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index 43f6f0d0e..33709873f 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -25,6 +25,7 @@ services: - ./databases:/var/www/html/storage/app/databases - ./services:/var/www/html/storage/app/services - ./backups:/var/www/html/storage/app/backups + - ./images:/var/www/html/storage/app/images env_file: - .env environment: diff --git a/docs/superpowers/specs/2026-08-17-external-tls-http-redirect-design.md b/docs/superpowers/specs/2026-08-17-external-tls-http-redirect-design.md new file mode 100644 index 000000000..7693ce13d --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-external-tls-http-redirect-design.md @@ -0,0 +1,90 @@ +# External TLS HTTP Redirect Design + +## Problem + +The Cloudflare Tunnel all-resource setup sends public HTTPS requests to Coolify's proxy through `http://localhost:80`. When a resource domain is stored as `https://` and Coolify redirects HTTP traffic to HTTPS, the tunneled request repeatedly returns to the HTTP entrypoint and causes `TOO_MANY_REDIRECTS`. + +The current documentation avoids the loop by telling users to store the public domain as `http://`. That misrepresents the public URL and can produce incorrect secure cookies, OAuth callback URLs, and canonical links. Applications can already disable forced HTTPS in advanced settings, but the control is not near domain configuration. Service applications always enable the redirect in generated proxy configuration. + +## Goals + +- Store the externally visible URL accurately as `https://`. +- Let an upstream proxy such as Cloudflare handle the HTTP-to-HTTPS redirect. +- Apply the behavior consistently to applications and service applications. +- Keep existing resources secure and behaviorally unchanged by default. +- Keep the feature generic rather than coupling it to Cloudflare or a server-wide tunnel mode. + +## Non-goals + +- Detect Cloudflare automatically. +- Add a server-wide all-resource tunnel mode. +- Configure trusted forwarded-header networks. +- Replace the end-to-end origin TLS workflow. +- Change the default redirect behavior of existing or new resources. + +## User Experience + +The Domains page shows a boolean control named **Redirect HTTP to HTTPS** when a resource has at least one `https://` domain. + +The control defaults to enabled. Its help text explains: + +> Disable this when HTTPS and redirects are handled by Cloudflare Tunnel or another reverse proxy that connects to Coolify over HTTP. + +A Cloudflare Tunnel user configures `https://app.example.com` and disables the control. A directly exposed resource leaves it enabled. + +For regular and Docker Compose applications, the control edits the existing `ApplicationSetting::is_force_https_enabled` value. The existing Advanced-page control must not become an independent source of truth; it should either be removed from that page or remain bound to the same setting with the clearer label. + +For service applications, the Domains page provides the same control for each application service. Database-only service entries do not expose it. + +## Data Model + +Add `is_force_https_enabled` to service applications as a non-null boolean with a default of `true`. Existing service applications therefore keep their current behavior after migration. + +Regular applications continue using the existing application setting. No Cloudflare-specific state is stored. + +## Proxy Configuration + +Domain scheme and redirect policy remain independent: + +- An `https://` domain continues generating the HTTPS router/listener. +- Its HTTP router/listener is also generated. +- When redirect is enabled, the HTTP router applies the HTTPS redirect middleware. +- When redirect is disabled, the HTTP router forwards the request to the resource without that middleware. + +The stored service-application setting replaces the currently hardcoded `true` passed into Traefik and Caddy label generation. Existing path stripping, gzip, authentication, noindex, and www/non-www middleware behavior remains unchanged. + +Preview deployments inherit the parent application's existing redirect setting, matching current application behavior. + +## Validation and Authorization + +The new service-application value is validated as a boolean. Updating it uses the same authorization checks as other service domain settings. Changing the value marks proxy configuration as changed and follows the existing save/redeploy flow used by domain configuration. + +The control is relevant only when an HTTPS domain exists. Hiding it for HTTP-only resources does not reset the stored value. + +## Documentation + +Update the Cloudflare all-resource guide to instruct users to: + +1. Store the public resource domain using `https://`. +2. Disable **Redirect HTTP to HTTPS** for that resource. +3. Let Cloudflare perform the public redirect and TLS termination. + +The guide should retain the full TLS guide as the alternative for users who want TLS between cloudflared and Coolify's HTTPS entrypoint. + +## Testing + +Automated tests must cover: + +- Application HTTPS domains with redirects enabled and disabled. +- Service-application HTTPS domains with redirects enabled and disabled. +- The service-application default remains enabled. +- Traefik and Caddy omit only the redirect behavior when disabled. +- Other middleware remains present when the redirect is disabled. +- HTTP-only resources do not show an irrelevant control. +- The Domains UI persists changes with existing authorization rules. + +A manual smoke test should route a Cloudflare Tunnel hostname to `http://localhost:80`, save the Coolify resource as `https://`, disable the redirect, and verify the public HTTPS URL loads without a redirect loop. + +## Compatibility + +The database default of `true` preserves service behavior. Existing application values are unchanged. No automatic migration attempts to infer which resources are behind Cloudflare. diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 9edc00e70..0d7caceb9 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -11,6 +11,7 @@ services: - /data/coolify/databases:/var/www/html/storage/app/databases - /data/coolify/services:/var/www/html/storage/app/services - /data/coolify/backups:/var/www/html/storage/app/backups + - /data/coolify/images:/var/www/html/storage/app/images environment: - APP_ENV=${APP_ENV:-production} - PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M} diff --git a/other/nightly/versions.json b/other/nightly/versions.json index e32d035c5..92a88ea02 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.3.6" + "version": "4.3.9" }, "nightly": { - "version": "4.3.7" + "version": "4.3.10" }, "helper": { "version": "1.0.15" diff --git a/resources/css/app.css b/resources/css/app.css index 95e0207ce..9dc39b7cc 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -398,9 +398,12 @@ html[data-theme="custom"] .animate-spin { color: var(--theme-bright-color) !important; } -/* Opt out of the brand spinner when the surrounding surface is a selected/neutral control. */ +/* Opt out of the brand spinner when the surrounding surface is a selected/neutral control + or a highlighted button, whose accent surface would camouflage a brand-colored spinner. */ .dark .animate-spin.spinner-current, -html[data-theme="custom"] .animate-spin.spinner-current { +html[data-theme="custom"] .animate-spin.spinner-current, +html[data-theme="custom"] .button-highlighted .animate-spin, +html[data-theme="custom"] button[isHighlighted] .animate-spin { color: inherit !important; } @@ -993,8 +996,7 @@ html[data-theme="custom"] { } html[data-theme="custom"] .control-selected, -html[data-theme="custom"] .logs-viewer-btn-active, -html[data-theme="custom"] .button-highlighted:hover { +html[data-theme="custom"] .logs-viewer-btn-active { color: var(--color-accent-foreground); } @@ -1905,7 +1907,13 @@ .dark .listbox-trigger { .listbox-trigger:disabled { cursor: not-allowed; - opacity: 0.5; + background-color: var(--color-neutral-100); + color: var(--color-neutral-400); +} + +.dark .listbox-trigger:disabled { + background-color: color-mix(in oklab, var(--color-white) 3%, transparent); + color: var(--color-fg-faint); } .listbox-trigger:focus-visible { diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 44218b0f3..6fdd260b5 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -126,12 +126,11 @@ @utility select { } @utility button { - /* h-9 matches input-select; nowrap + shrink-0 keep side-by-side action rows equal height */ - @apply inline-flex shrink-0 gap-1.5 justify-center items-center whitespace-nowrap px-2.5 h-9 min-h-9 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent; + @apply inline-flex shrink-0 gap-1.5 justify-center items-center whitespace-nowrap px-2.5 h-8 min-h-8 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent; } @utility button-highlighted { - @apply border-coollabs-200 bg-linear-to-b from-coollabs-100 to-coollabs-200 text-white! hover:from-coollabs-100 hover:to-coollabs hover:text-white!; + @apply border-coollabs-200 bg-linear-to-b from-coollabs-100 to-coollabs-200 text-accent-foreground! hover:from-coollabs-100 hover:to-coollabs hover:text-accent-foreground!; } @utility control-selected { diff --git a/resources/views/auth/two-factor-challenge.blade.php b/resources/views/auth/two-factor-challenge.blade.php index 4170b188f..b15afc24d 100644 --- a/resources/views/auth/two-factor-challenge.blade.php +++ b/resources/views/auth/two-factor-challenge.blade.php @@ -2,35 +2,13 @@
@if (session('status')) {{ session('status') }} @@ -56,17 +34,11 @@ @csrf
- -
- -
+
diff --git a/resources/views/components/database-status-info.blade.php b/resources/views/components/database-status-info.blade.php index 5e352c206..b9298e268 100644 --- a/resources/views/components/database-status-info.blade.php +++ b/resources/views/components/database-status-info.blade.php @@ -65,7 +65,7 @@
@endif
- @if ($sslModeOptions) - +
@if ($label) @endif
+ + +
diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 006efb2ca..41cbd7ce9 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -55,6 +55,8 @@ 'icon' => 'network', 'group' => 'Platform', 'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server, + 'warning' => $server->hasCurrentTraefikOutdatedInfo(), + 'tracks_proxy_configuration' => true, 'children' => [ ['label' => 'Configuration', 'route' => 'server.proxy', 'active' => $activeSubMenu === 'configuration', 'icon' => 'settings'], ['label' => 'Dynamic Configurations', 'route' => 'server.proxy.dynamic-confs', 'active' => $activeSubMenu === 'dynamic-confs', 'icon' => 'sliders', 'visible' => $server->proxySet()], @@ -167,7 +169,15 @@ $groupedServerMenuItems = $serverMenuItems->groupBy('group'); @endphp -