diff --git a/.env.development.example b/.env.development.example index 594b89201..d02b8ba59 100644 --- a/.env.development.example +++ b/.env.development.example @@ -15,6 +15,18 @@ DB_PASSWORD=password DB_HOST=host.docker.internal DB_PORT=5432 +# Read/write replicas (optional). Set DB_READ_HOST to enable the read/write split. +# Hosts may be comma-separated. Port/username/password fall back to DB_* when unset. +# DB_READ_HOST=replica1,replica2 +# DB_READ_PORT=5432 +# DB_READ_USERNAME=coolify +# DB_READ_PASSWORD= +# DB_WRITE_HOST= +# DB_WRITE_PORT=5432 +# DB_WRITE_USERNAME=coolify +# DB_WRITE_PASSWORD= +# DB_STICKY=true + # Ray Configuration # Set to true to enable Ray RAY_ENABLED=false diff --git a/README.md b/README.md index 28fc33d6f..b387d87e8 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,9 @@ ### Huge Sponsors * [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality * [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API +* [Seibert Group](https://seibert.link/coolifysoftware?ref=coolify.io) - Boost productivity company-wide with AI agents like Claude Code * [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs -* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers Infrastructure for people who care about privacy and control +* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control ### Big Sponsors @@ -70,13 +71,11 @@ ### Big Sponsors * [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner * [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform * [Capture.page](https://capture.page/?ref=coolify.io) - Fast & Reliable Screenshot API for Developers -* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain * [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale * [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half * [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor * [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform * [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers -* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy * [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design * [Dataforest Cloud](https://cloud.dataforest.net/en?ref=coolify.io) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany. * [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 30cae71f1..525e736c3 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -50,13 +50,9 @@ public function handle(StandaloneClickhouse $database) ], ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -98,6 +94,9 @@ public function handle(StandaloneClickhouse $database) $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); + if (! $this->database->isHealthcheckEnabled()) { + unset($docker_compose['services'][$container_name]['healthcheck']); + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php index e2fa6fc87..4b55b0c1d 100644 --- a/app/Actions/Database/StartDatabase.php +++ b/app/Actions/Database/StartDatabase.php @@ -11,12 +11,16 @@ use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use Lorisleiva\Actions\Concerns\AsAction; +use Lorisleiva\Actions\Decorators\JobDecorator; class StartDatabase { use AsAction; - public string $jobQueue = 'high'; + public function configureJob(JobDecorator $job): void + { + $job->onQueue(deployment_queue()); + } public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database) { @@ -25,28 +29,28 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St return 'Server is not functional'; } switch ($database->getMorphClass()) { - case \App\Models\StandalonePostgresql::class: + case StandalonePostgresql::class: $activity = StartPostgresql::run($database); break; - case \App\Models\StandaloneRedis::class: + case StandaloneRedis::class: $activity = StartRedis::run($database); break; - case \App\Models\StandaloneMongodb::class: + case StandaloneMongodb::class: $activity = StartMongodb::run($database); break; - case \App\Models\StandaloneMysql::class: + case StandaloneMysql::class: $activity = StartMysql::run($database); break; - case \App\Models\StandaloneMariadb::class: + case StandaloneMariadb::class: $activity = StartMariadb::run($database); break; - case \App\Models\StandaloneKeydb::class: + case StandaloneKeydb::class: $activity = StartKeydb::run($database); break; - case \App\Models\StandaloneDragonfly::class: + case StandaloneDragonfly::class: $activity = StartDragonfly::run($database); break; - case \App\Models\StandaloneClickhouse::class: + case StandaloneClickhouse::class: $activity = StartClickhouse::run($database); break; } diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index fa39f7909..1057d1e4d 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -11,14 +11,19 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Notifications\Container\ContainerRestarted; use Lorisleiva\Actions\Concerns\AsAction; +use Lorisleiva\Actions\Decorators\JobDecorator; use Symfony\Component\Yaml\Yaml; class StartDatabaseProxy { use AsAction; - public string $jobQueue = 'high'; + public function configureJob(JobDecorator $job): void + { + $job->onQueue(deployment_queue()); + } public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database) { @@ -29,7 +34,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St $proxyContainerName = "{$database->uuid}-proxy"; $isSSLEnabled = $database->enable_ssl ?? false; - if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($database->getMorphClass() === ServiceDatabase::class) { $databaseType = $database->databaseType(); $network = $database->service->uuid; $server = data_get($database, 'service.destination.server'); @@ -132,7 +137,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St ?? data_get($database, 'service.environment.project.team'); $team?->notify( - new \App\Notifications\Container\ContainerRestarted( + new ContainerRestarted( "TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}", $server, ) diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index addc30be4..b78a0987d 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -106,13 +106,9 @@ public function handle(StandaloneDragonfly $database) $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -182,6 +178,9 @@ public function handle(StandaloneDragonfly $database) $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); + if (! $this->database->isHealthcheckEnabled()) { + unset($docker_compose['services'][$container_name]['healthcheck']); + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index e59d6f697..89258fe24 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -108,13 +108,9 @@ public function handle(StandaloneKeydb $database) $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -197,6 +193,9 @@ public function handle(StandaloneKeydb $database) // Add custom docker run options $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); + if (! $this->database->isHealthcheckEnabled()) { + unset($docker_compose['services'][$container_name]['healthcheck']); + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index ceb1e8b85..2e8faea9a 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -103,13 +103,9 @@ public function handle(StandaloneMariadb $database) $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'healthcheck.sh', '--connect', '--innodb_initialized', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -202,6 +198,9 @@ public function handle(StandaloneMariadb $database) ]; } + if (! $this->database->isHealthcheckEnabled()) { + unset($docker_compose['services'][$container_name]['healthcheck']); + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index c79789718..80ec812a1 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -109,17 +109,11 @@ public function handle(StandaloneMongodb $database) $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => [ - 'CMD', - 'echo', - 'ok', - ], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', + 'echo', + 'ok', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -253,6 +247,9 @@ public function handle(StandaloneMongodb $database) $docker_compose['services'][$container_name]['command'] = $commandParts; } + if (! $this->database->isHealthcheckEnabled()) { + unset($docker_compose['services'][$container_name]['healthcheck']); + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 0394d50b6..0445bddcd 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -103,13 +103,9 @@ public function handle(StandaloneMysql $database) $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}"], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}", + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -203,6 +199,9 @@ public function handle(StandaloneMysql $database) ]; } + if (! $this->database->isHealthcheckEnabled()) { + unset($docker_compose['services'][$container_name]['healthcheck']); + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index da8b5dc4e..ae7ae9860 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -110,13 +110,9 @@ public function handle(StandalonePostgresql $database) $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -213,6 +209,9 @@ public function handle(StandalonePostgresql $database) $docker_compose['services'][$container_name]['command'] = $command; } + if (! $this->database->isHealthcheckEnabled()) { + unset($docker_compose['services'][$container_name]['healthcheck']); + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index c31b099e4..64b434821 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -105,17 +105,11 @@ public function handle(StandaloneRedis $database) $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => [ - 'CMD-SHELL', - 'redis-cli', - 'ping', - ], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD-SHELL', + 'redis-cli', + 'ping', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -194,6 +188,9 @@ public function handle(StandaloneRedis $database) $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); + if (! $this->database->isHealthcheckEnabled()) { + unset($docker_compose['services'][$container_name]['healthcheck']); + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 7ea6a871e..cddf66389 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -2,6 +2,7 @@ namespace App\Actions\Fortify; +use App\Models\Team; use App\Models\User; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; @@ -44,7 +45,10 @@ public function create(array $input): User 'password' => Hash::make($input['password']), ]); $user->save(); - $team = $user->teams()->first(); + $team = $user->teams()->first() ?? Team::find(0); + if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) { + $user->teams()->attach($team, ['role' => 'owner']); + } // Disable registration after first user is created $settings = instanceSettings(); diff --git a/app/Actions/Server/ResourcesCheck.php b/app/Actions/Server/ResourcesCheck.php deleted file mode 100644 index e6b90ba38..000000000 --- a/app/Actions/Server/ResourcesCheck.php +++ /dev/null @@ -1,41 +0,0 @@ -subSeconds($seconds))->update(['status' => 'exited']); - ServiceApplication::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - ServiceDatabase::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - StandalonePostgresql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - StandaloneRedis::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - StandaloneMongodb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - StandaloneMysql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - StandaloneMariadb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - StandaloneKeydb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - StandaloneDragonfly::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - StandaloneClickhouse::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - } catch (\Throwable $e) { - return handleError($e); - } - } -} diff --git a/app/Actions/Service/RestartService.php b/app/Actions/Service/RestartService.php index d38ef54d6..6acd3b0a4 100644 --- a/app/Actions/Service/RestartService.php +++ b/app/Actions/Service/RestartService.php @@ -13,8 +13,10 @@ class RestartService public function handle(Service $service, bool $pullLatestImages) { - StopService::run($service); - - return StartService::run($service, $pullLatestImages); + return StartService::run( + service: $service, + pullLatestImages: $pullLatestImages, + stopBeforeStart: true, + ); } } diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 17948d93b..89817506d 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -4,18 +4,22 @@ use App\Models\Service; use Lorisleiva\Actions\Concerns\AsAction; +use Lorisleiva\Actions\Decorators\JobDecorator; use Symfony\Component\Yaml\Yaml; class StartService { use AsAction; - public string $jobQueue = 'high'; + public function configureJob(JobDecorator $job): void + { + $job->onQueue(deployment_queue()); + } public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) { $service->parse(); - if ($stopBeforeStart) { + if ($this->shouldStopBeforeStarting($pullLatestImages, $stopBeforeStart)) { StopService::run(service: $service, dockerCleanup: false); } $service->saveComposeConfigs(); @@ -49,4 +53,9 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); } + + private function shouldStopBeforeStarting(bool $pullLatestImages, bool $stopBeforeStart): bool + { + return $stopBeforeStart && ! $pullLatestImages; + } } diff --git a/app/Actions/User/DeleteUserTeams.php b/app/Actions/User/DeleteUserTeams.php index d572db9e7..b2b06e7ba 100644 --- a/app/Actions/User/DeleteUserTeams.php +++ b/app/Actions/User/DeleteUserTeams.php @@ -137,9 +137,11 @@ public function execute(): array // Update the new owner's role to owner $team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']); + RevokeUserTeamTokens::forUserTeam($newOwner, $team->id); // Remove the current user from the team $team->members()->detach($this->user->id); + RevokeUserTeamTokens::forUserTeam($this->user, $team->id); $counts['transferred']++; } catch (\Exception $e) { @@ -152,6 +154,7 @@ public function execute(): array foreach ($preview['to_leave'] as $team) { try { $team->members()->detach($this->user->id); + RevokeUserTeamTokens::forUserTeam($this->user, $team->id); $counts['left']++; } catch (\Exception $e) { \Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage()); diff --git a/app/Actions/User/RevokeUserTeamTokens.php b/app/Actions/User/RevokeUserTeamTokens.php new file mode 100644 index 000000000..9aadf1eeb --- /dev/null +++ b/app/Actions/User/RevokeUserTeamTokens.php @@ -0,0 +1,43 @@ +where('tokenable_id', self::userId($user)) + ->where('team_id', $teamId) + ->delete(); + } + + public static function forUser(User|int $user): int + { + return self::baseQuery() + ->where('tokenable_id', self::userId($user)) + ->delete(); + } + + public static function forTeam(int|string $teamId): int + { + return self::baseQuery() + ->where('team_id', $teamId) + ->delete(); + } + + private static function baseQuery(): Builder + { + return PersonalAccessToken::query() + ->where('tokenable_type', User::class); + } + + private static function userId(User|int $user): int + { + return $user instanceof User ? $user->id : $user; + } +} diff --git a/app/Console/Commands/CleanupUnreachableServers.php b/app/Console/Commands/CleanupUnreachableServers.php index 09563a2c3..666e98a18 100644 --- a/app/Console/Commands/CleanupUnreachableServers.php +++ b/app/Console/Commands/CleanupUnreachableServers.php @@ -18,9 +18,13 @@ public function handle() if ($servers->count() > 0) { foreach ($servers as $server) { echo "Cleanup unreachable server ($server->id) with name $server->name"; - $server->update([ - 'ip' => '1.2.3.4', - ]); + if (isCloud()) { + $server->update([ + 'ip' => '1.2.3.4', + ]); + } else { + $server->forceDisableServer(); + } } } } diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index 9ac3371e0..d6d77f22e 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -16,7 +16,7 @@ class SyncBunny extends Command * * @var string */ - protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}'; + protected $signature = 'sync:bunny {--templates} {--release} {--nightly}'; /** * The console command description. @@ -25,650 +25,6 @@ class SyncBunny extends Command */ protected $description = 'Sync files to BunnyCDN'; - /** - * Fetch GitHub releases and sync to GitHub repository - */ - private function syncReleasesToGitHubRepo(): bool - { - $this->info('Fetching releases from GitHub...'); - try { - $response = Http::timeout(30) - ->get('https://api.github.com/repos/coollabsio/coolify/releases', [ - 'per_page' => 30, // Fetch more releases for better changelog - ]); - - if (! $response->successful()) { - $this->error('Failed to fetch releases from GitHub: '.$response->status()); - - return false; - } - - $releases = $response->json(); - $timestamp = time(); - $tmpDir = sys_get_temp_dir().'/coolify-cdn-'.$timestamp; - $branchName = 'update-releases-'.$timestamp; - - // Clone the repository - $this->info('Cloning coolify-cdn repository...'); - $output = []; - exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to clone repository: '.implode("\n", $output)); - - return false; - } - - // Create feature branch - $this->info('Creating feature branch...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to create branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Write releases.json - $this->info('Writing releases.json...'); - $releasesPath = "$tmpDir/json/releases.json"; - $releasesDir = dirname($releasesPath); - - // Ensure directory exists - if (! is_dir($releasesDir)) { - $this->info("Creating directory: $releasesDir"); - if (! mkdir($releasesDir, 0755, true)) { - $this->error("Failed to create directory: $releasesDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - $jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $bytesWritten = file_put_contents($releasesPath, $jsonContent); - - if ($bytesWritten === false) { - $this->error("Failed to write releases.json to: $releasesPath"); - $this->error('Possible reasons: permission denied or disk full.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Stage and commit - $this->info('Committing changes...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to stage changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - $this->info('Checking for changes...'); - $statusOutput = []; - exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain json/releases.json 2>&1', $statusOutput, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - if (empty(array_filter($statusOutput))) { - $this->info('Releases are already up to date. No changes to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - $commitMessage = 'Update releases.json with latest releases - '.date('Y-m-d H:i:s'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to commit changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Push to remote - $this->info('Pushing branch to remote...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to push branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Create pull request - $this->info('Creating pull request...'); - $prTitle = 'Update releases.json - '.date('Y-m-d H:i:s'); - $prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API'; - $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; - $output = []; - exec($prCommand, $output, $returnCode); - - // Clean up - exec('rm -rf '.escapeshellarg($tmpDir)); - - if ($returnCode !== 0) { - $this->error('Failed to create PR: '.implode("\n", $output)); - - return false; - } - - $this->info('Pull request created successfully!'); - if (! empty($output)) { - $this->info('PR Output: '.implode("\n", $output)); - } - $this->info('Total releases synced: '.count($releases)); - - return true; - } catch (\Throwable $e) { - $this->error('Error syncing releases: '.$e->getMessage()); - - return false; - } - } - - /** - * Sync both releases.json and versions.json to GitHub repository in one PR - */ - private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool - { - $this->info('Syncing releases.json and versions.json to GitHub repository...'); - try { - // 1. Fetch releases from GitHub API - $this->info('Fetching releases from GitHub API...'); - $response = Http::timeout(30) - ->get('https://api.github.com/repos/coollabsio/coolify/releases', [ - 'per_page' => 30, - ]); - - if (! $response->successful()) { - $this->error('Failed to fetch releases from GitHub: '.$response->status()); - - return false; - } - - $releases = $response->json(); - - // 2. Read versions.json - if (! file_exists($versionsLocation)) { - $this->error("versions.json not found at: $versionsLocation"); - - return false; - } - - $file = file_get_contents($versionsLocation); - $versionsJson = json_decode($file, true); - $actualVersion = data_get($versionsJson, 'coolify.v4.version'); - - $timestamp = time(); - $tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp; - $branchName = 'update-releases-and-versions-'.$timestamp; - $versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json'; - - // 3. Clone the repository - $this->info('Cloning coolify-cdn repository...'); - $output = []; - exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to clone repository: '.implode("\n", $output)); - - return false; - } - - // 4. Create feature branch - $this->info('Creating feature branch...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to create branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 5. Write releases.json - $this->info('Writing releases.json...'); - $releasesPath = "$tmpDir/json/releases.json"; - $releasesDir = dirname($releasesPath); - - if (! is_dir($releasesDir)) { - if (! mkdir($releasesDir, 0755, true)) { - $this->error("Failed to create directory: $releasesDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - $releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - if (file_put_contents($releasesPath, $releasesJsonContent) === false) { - $this->error("Failed to write releases.json to: $releasesPath"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 6. Write versions.json - $this->info('Writing versions.json...'); - $versionsPath = "$tmpDir/$versionsTargetPath"; - $versionsDir = dirname($versionsPath); - - if (! is_dir($versionsDir)) { - if (! mkdir($versionsDir, 0755, true)) { - $this->error("Failed to create directory: $versionsDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - $versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - if (file_put_contents($versionsPath, $versionsJsonContent) === false) { - $this->error("Failed to write versions.json to: $versionsPath"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 7. Stage both files - $this->info('Staging changes...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to stage changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 8. Check for changes - $this->info('Checking for changes...'); - $statusOutput = []; - exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - if (empty(array_filter($statusOutput))) { - $this->info('Both files are already up to date. No changes to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - // 9. Commit changes - $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to commit changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 10. Push to remote - $this->info('Pushing branch to remote...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to push branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 11. Create pull request - $this->info('Creating pull request...'); - $prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); - $prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion"; - $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; - $output = []; - exec($prCommand, $output, $returnCode); - - // 12. Clean up - exec('rm -rf '.escapeshellarg($tmpDir)); - - if ($returnCode !== 0) { - $this->error('Failed to create PR: '.implode("\n", $output)); - - return false; - } - - $this->info('Pull request created successfully!'); - if (! empty($output)) { - $this->info('PR URL: '.implode("\n", $output)); - } - $this->info("Version synced: $actualVersion"); - $this->info('Total releases synced: '.count($releases)); - - return true; - } catch (\Throwable $e) { - $this->error('Error syncing to GitHub: '.$e->getMessage()); - - return false; - } - } - - /** - * Sync install.sh, docker-compose, and env files to GitHub repository via PR - */ - private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool - { - $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $this->info("Syncing $envLabel files to GitHub repository..."); - try { - $timestamp = time(); - $tmpDir = sys_get_temp_dir().'/coolify-cdn-files-'.$timestamp; - $branchName = 'update-files-'.$timestamp; - - // Clone the repository - $this->info('Cloning coolify-cdn repository...'); - $output = []; - exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to clone repository: '.implode("\n", $output)); - - return false; - } - - // Create feature branch - $this->info('Creating feature branch...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to create branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Copy each file to its target path in the CDN repo - $copiedFiles = []; - foreach ($files as $sourceFile => $targetPath) { - if (! file_exists($sourceFile)) { - $this->warn("Source file not found, skipping: $sourceFile"); - - continue; - } - - $destPath = "$tmpDir/$targetPath"; - $destDir = dirname($destPath); - - if (! is_dir($destDir)) { - if (! mkdir($destDir, 0755, true)) { - $this->error("Failed to create directory: $destDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - if (copy($sourceFile, $destPath) === false) { - $this->error("Failed to copy $sourceFile to $destPath"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - $copiedFiles[] = $targetPath; - $this->info("Copied: $targetPath"); - } - - if (empty($copiedFiles)) { - $this->warn('No files were copied. Nothing to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - // Stage all copied files - $this->info('Staging changes...'); - $output = []; - $stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1'; - exec($stageCmd, $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to stage changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Check for changes - $this->info('Checking for changes...'); - $statusOutput = []; - exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - if (empty(array_filter($statusOutput))) { - $this->info('All files are already up to date. No changes to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - // Commit changes - $commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to commit changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Push to remote - $this->info('Pushing branch to remote...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to push branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Create pull request - $this->info('Creating pull request...'); - $prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s'); - $fileList = implode("\n- ", $copiedFiles); - $prBody = "Automated update of $envLabel files:\n- $fileList"; - $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; - $output = []; - exec($prCommand, $output, $returnCode); - - // Clean up - exec('rm -rf '.escapeshellarg($tmpDir)); - - if ($returnCode !== 0) { - $this->error('Failed to create PR: '.implode("\n", $output)); - - return false; - } - - $this->info('Pull request created successfully!'); - if (! empty($output)) { - $this->info('PR URL: '.implode("\n", $output)); - } - $this->info('Files synced: '.count($copiedFiles)); - - return true; - } catch (\Throwable $e) { - $this->error('Error syncing files to GitHub: '.$e->getMessage()); - - return false; - } - } - - /** - * Sync versions.json to GitHub repository via PR - */ - private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool - { - $this->info('Syncing versions.json to GitHub repository...'); - try { - if (! file_exists($versionsLocation)) { - $this->error("versions.json not found at: $versionsLocation"); - - return false; - } - - $file = file_get_contents($versionsLocation); - $json = json_decode($file, true); - $actualVersion = data_get($json, 'coolify.v4.version'); - - $timestamp = time(); - $tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp; - $branchName = 'update-versions-'.$timestamp; - $targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json'; - - // Clone the repository - $this->info('Cloning coolify-cdn repository...'); - exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to clone repository: '.implode("\n", $output)); - - return false; - } - - // Create feature branch - $this->info('Creating feature branch...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to create branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Write versions.json - $this->info('Writing versions.json...'); - $versionsPath = "$tmpDir/$targetPath"; - $versionsDir = dirname($versionsPath); - - // Ensure directory exists - if (! is_dir($versionsDir)) { - $this->info("Creating directory: $versionsDir"); - if (! mkdir($versionsDir, 0755, true)) { - $this->error("Failed to create directory: $versionsDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - $jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $bytesWritten = file_put_contents($versionsPath, $jsonContent); - - if ($bytesWritten === false) { - $this->error("Failed to write versions.json to: $versionsPath"); - $this->error('Possible reasons: permission denied or disk full.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Stage and commit - $this->info('Committing changes...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to stage changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - $this->info('Checking for changes...'); - $statusOutput = []; - exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 2>&1', $statusOutput, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - if (empty(array_filter($statusOutput))) { - $this->info('versions.json is already up to date. No changes to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $commitMessage = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to commit changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Push to remote - $this->info('Pushing branch to remote...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to push branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Create pull request - $this->info('Creating pull request...'); - $prTitle = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); - $prBody = "Automated update of $envLabel versions.json to version $actualVersion"; - $output = []; - $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; - exec($prCommand, $output, $returnCode); - - // Clean up - exec('rm -rf '.escapeshellarg($tmpDir)); - - if ($returnCode !== 0) { - $this->error('Failed to create PR: '.implode("\n", $output)); - - return false; - } - - $this->info('Pull request created successfully!'); - if (! empty($output)) { - $this->info('PR URL: '.implode("\n", $output)); - } - $this->info("Version synced: $actualVersion"); - - return true; - } catch (\Throwable $e) { - $this->error('Error syncing versions.json: '.$e->getMessage()); - - return false; - } - } - /** * Execute the console command. */ @@ -677,8 +33,6 @@ public function handle() $that = $this; $only_template = $this->option('templates'); $only_version = $this->option('release'); - $only_github_releases = $this->option('github-releases'); - $only_github_versions = $this->option('github-versions'); $nightly = $this->option('nightly'); $bunny_cdn = 'https://cdn.coollabs.io'; $bunny_cdn_path = 'coolify'; @@ -736,30 +90,11 @@ public function handle() $install_script_location = "$parent_dir/other/nightly/$install_script"; $versions_location = "$parent_dir/other/nightly/$versions"; } - if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) { + if (! $only_template && ! $only_version) { $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $this->info("About to sync $envLabel files to BunnyCDN and create a GitHub PR for coolify-cdn."); + $this->info("About to sync $envLabel files to BunnyCDN."); $this->newLine(); - // Build file mapping for diff - if ($nightly) { - $fileMapping = [ - $compose_file_location => 'docker/nightly/docker-compose.yml', - $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml', - $production_env_location => 'environment/nightly/.env.production', - $upgrade_script_location => 'scripts/nightly/upgrade.sh', - $install_script_location => 'scripts/nightly/install.sh', - ]; - } else { - $fileMapping = [ - $compose_file_location => 'docker/docker-compose.yml', - $compose_file_prod_location => 'docker/docker-compose.prod.yml', - $production_env_location => 'environment/.env.production', - $upgrade_script_location => 'scripts/upgrade.sh', - $install_script_location => 'scripts/install.sh', - ]; - } - // BunnyCDN file mapping (local file => CDN URL path) $bunnyFileMapping = [ $compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file", @@ -812,44 +147,6 @@ public function handle() } } - // Diff against GitHub coolify-cdn repo - $this->newLine(); - $this->info('Fetching coolify-cdn repo to compare...'); - $output = []; - exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg("$diffTmpDir/repo").' -- --depth 1 2>&1', $output, $returnCode); - - if ($returnCode === 0) { - foreach ($fileMapping as $localFile => $cdnPath) { - $remotePath = "$diffTmpDir/repo/$cdnPath"; - if (! file_exists($localFile)) { - continue; - } - if (! file_exists($remotePath)) { - $this->info("NEW on GitHub: $cdnPath (does not exist in coolify-cdn yet)"); - $hasChanges = true; - - continue; - } - - $diffOutput = []; - exec('diff -u '.escapeshellarg($remotePath).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode); - if ($diffCode !== 0) { - $hasChanges = true; - $this->newLine(); - $this->info("--- GitHub: $cdnPath"); - $this->info("+++ Local: $cdnPath"); - foreach ($diffOutput as $line) { - if (str_starts_with($line, '---') || str_starts_with($line, '+++')) { - continue; - } - $this->line($line); - } - } - } - } else { - $this->warn('Could not fetch coolify-cdn repo for diff.'); - } - exec('rm -rf '.escapeshellarg($diffTmpDir)); if (! $hasChanges) { @@ -881,9 +178,9 @@ public function handle() return; } elseif ($only_version) { if ($nightly) { - $this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.'); + $this->info('About to sync NIGHTLY versions.json to BunnyCDN.'); } else { - $this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.'); + $this->info('About to sync PRODUCTION versions.json to BunnyCDN.'); } $file = file_get_contents($versions_location); $json = json_decode($file, true); @@ -891,8 +188,7 @@ public function handle() $this->info("Version: {$actual_version}"); $this->info('This will:'); - $this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)'); - $this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json'); + $this->info(' 1. Sync versions.json to BunnyCDN'); $this->newLine(); $confirmed = confirm('Are you sure you want to proceed?'); @@ -900,8 +196,7 @@ public function handle() return; } - // 1. Sync versions.json to BunnyCDN (deprecated but still needed) - $this->info('Step 1/2: Syncing versions.json to BunnyCDN...'); + $this->info('Syncing versions.json to BunnyCDN...'); Http::pool(fn (Pool $pool) => [ $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"), @@ -909,46 +204,8 @@ public function handle() $this->info('✓ versions.json uploaded & purged to BunnyCDN'); $this->newLine(); - // 2. Create GitHub PR with both releases.json and versions.json - $this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...'); - $githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly); - if ($githubSuccess) { - $this->info('✓ GitHub PR created successfully with both files'); - } else { - $this->error('✗ Failed to create GitHub PR'); - } - $this->newLine(); - $this->info('=== Summary ==='); $this->info('BunnyCDN sync: ✓ Complete'); - $this->info('GitHub PR: '.($githubSuccess ? '✓ Created (releases.json + versions.json)' : '✗ Failed')); - - return; - } elseif ($only_github_releases) { - $this->info('About to sync GitHub releases to GitHub repository.'); - $confirmed = confirm('Are you sure you want to sync GitHub releases?'); - if (! $confirmed) { - return; - } - - // Sync releases to GitHub repository - $this->syncReleasesToGitHubRepo(); - - return; - } elseif ($only_github_versions) { - $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $file = file_get_contents($versions_location); - $json = json_decode($file, true); - $actual_version = data_get($json, 'coolify.v4.version'); - - $this->info("About to sync $envLabel versions.json ($actual_version) to GitHub repository."); - $confirmed = confirm('Are you sure you want to sync versions.json via GitHub PR?'); - if (! $confirmed) { - return; - } - - // Sync versions.json to GitHub repository - $this->syncVersionsToGitHubRepo($versions_location, $nightly); return; } @@ -970,31 +227,8 @@ public function handle() $this->info('All files uploaded & purged to BunnyCDN.'); $this->newLine(); - // Sync files to GitHub CDN repository via PR - $this->info('Creating GitHub PR for coolify-cdn repository...'); - if ($nightly) { - $files = [ - $compose_file_location => 'docker/nightly/docker-compose.yml', - $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml', - $production_env_location => 'environment/nightly/.env.production', - $upgrade_script_location => 'scripts/nightly/upgrade.sh', - $install_script_location => 'scripts/nightly/install.sh', - ]; - } else { - $files = [ - $compose_file_location => 'docker/docker-compose.yml', - $compose_file_prod_location => 'docker/docker-compose.prod.yml', - $production_env_location => 'environment/.env.production', - $upgrade_script_location => 'scripts/upgrade.sh', - $install_script_location => 'scripts/install.sh', - ]; - } - - $githubSuccess = $this->syncFilesToGitHubRepo($files, $nightly); - $this->newLine(); $this->info('=== Summary ==='); $this->info('BunnyCDN sync: Complete'); - $this->info('GitHub PR: '.($githubSuccess ? 'Created' : 'Failed')); } catch (\Throwable $e) { $this->error('Error: '.$e->getMessage()); } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 75ec31ae0..e97105836 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -40,7 +40,6 @@ protected function schedule(Schedule $schedule): void $this->instanceTimezone = config('app.timezone'); } - // $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly(); $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer(); $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); @@ -78,7 +77,7 @@ protected function schedule(Schedule $schedule): void // Scheduled Jobs (Backups & Tasks) $this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer(); - $this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily(); + $this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily()->onOneServer(); $this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer(); diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 4629df571..d0bd8d3a3 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -4,7 +4,6 @@ use App\Models\PrivateKey; use App\Models\Server; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; @@ -12,145 +11,65 @@ class SshMultiplexingHelper { - public static function serverSshConfiguration(Server $server) + public static function serverSshConfiguration(Server $server): array { $privateKey = PrivateKey::findOrFail($server->private_key_id); - $sshKeyLocation = $privateKey->getKeyLocation(); - $muxFilename = '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid; return [ - 'sshKeyLocation' => $sshKeyLocation, - 'muxFilename' => $muxFilename, + 'sshKeyLocation' => $privateKey->getKeyLocation(), + 'muxFilename' => self::muxSocket($server), ]; } public static function ensureMultiplexedConnection(Server $server): bool { - if (! self::isMultiplexingEnabled()) { - return false; - } - - $sshConfig = self::serverSshConfiguration($server); - $muxSocket = $sshConfig['muxFilename']; - - // Check if connection exists - $checkCommand = "ssh -O check -o ControlPath=$muxSocket "; - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; - } - $checkCommand .= self::escapedUserAtHost($server); - $process = Process::run($checkCommand); - - if ($process->exitCode() !== 0) { - return self::establishNewMultiplexedConnection($server); - } - - // Connection exists, ensure we have metadata for age tracking - if (self::getConnectionAge($server) === null) { - // Existing connection but no metadata, store current time as fallback - self::storeConnectionMetadata($server); - } - - // Connection exists, check if it needs refresh due to age - if (self::isConnectionExpired($server)) { - return self::refreshMultiplexedConnection($server); - } - - // Perform health check if enabled - if (config('constants.ssh.mux_health_check_enabled')) { - if (! self::isConnectionHealthy($server)) { - return self::refreshMultiplexedConnection($server); - } - } - - return true; + return self::isMultiplexingEnabled(); } - public static function establishNewMultiplexedConnection(Server $server): bool + public static function removeMuxFile(Server $server): void { - $sshConfig = self::serverSshConfiguration($server); - $sshKeyLocation = $sshConfig['sshKeyLocation']; - $muxSocket = $sshConfig['muxFilename']; - $connectionTimeout = self::getConnectionTimeout($server); - $serverInterval = config('constants.ssh.server_interval'); - $muxPersistTime = config('constants.ssh.mux_persist_time'); - - $establishCommand = "ssh -fNM -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; - - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" '; - } - $establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval); - $establishCommand .= self::escapedUserAtHost($server); - $establishProcess = Process::run($establishCommand); - if ($establishProcess->exitCode() !== 0) { - return false; - } - - // Store connection metadata for tracking - self::storeConnectionMetadata($server); - - return true; - } - - public static function removeMuxFile(Server $server) - { - $sshConfig = self::serverSshConfiguration($server); - $muxSocket = $sshConfig['muxFilename']; - - $closeCommand = "ssh -O exit -o ControlPath=$muxSocket "; - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $closeCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; - } - $closeCommand .= self::escapedUserAtHost($server); + $closeCommand = self::muxControlCommand($server, 'exit'); Process::run($closeCommand); - - // Clear connection metadata from cache - self::clearConnectionMetadata($server); } - public static function generateScpCommand(Server $server, string $source, string $dest) + private static function muxControlCommand(Server $server, string $operation): string + { + $command = "ssh -O {$operation} -o ControlPath=".self::muxSocket($server).' '; + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; + } + + return $command.self::escapedUserAtHost($server); + } + + public static function generateScpCommand(Server $server, string $source, string $dest): string { $sshConfig = self::serverSshConfiguration($server); $sshKeyLocation = $sshConfig['sshKeyLocation']; - $muxSocket = $sshConfig['muxFilename']; + $scpCommand = 'timeout '.config('constants.ssh.command_timeout').' scp '; - $timeout = config('constants.ssh.command_timeout'); - $muxPersistTime = config('constants.ssh.mux_persist_time'); - - $scp_command = "timeout $timeout scp "; if ($server->isIpv6()) { - $scp_command .= '-6 '; + $scpCommand .= '-6 '; } + if (self::isMultiplexingEnabled()) { - try { - if (self::ensureMultiplexedConnection($server)) { - $scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; - } - } catch (\Exception $e) { - Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [ - 'server' => $server->name ?? $server->ip, - 'error' => $e->getMessage(), - ]); - // Continue without multiplexing - } + $scpCommand .= self::multiplexingOptions($server); } if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; + $scpCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; } - $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true); + $scpCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true); + if ($server->isIpv6()) { - $scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}"; - } else { - $scp_command .= "{$source} ".self::escapedUserAtHost($server).":{$dest}"; + return $scpCommand.escapeshellarg($source).' '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($dest); } - return $scp_command; + return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest); } - public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false) + public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false, ?int $commandTimeout = null): string { if ($server->settings->force_disabled) { throw new \RuntimeException('Server is disabled.'); @@ -161,40 +80,37 @@ public static function generateSshCommand(Server $server, string $command, bool self::validateSshKey($server->privateKey); - $muxSocket = $sshConfig['muxFilename']; + $commandTimeout = $commandTimeout ?? (int) config('constants.ssh.command_timeout'); + $sshCommand = $commandTimeout > 0 ? "timeout {$commandTimeout} ssh " : 'ssh '; - $timeout = config('constants.ssh.command_timeout'); - $muxPersistTime = config('constants.ssh.mux_persist_time'); - - $ssh_command = "timeout $timeout ssh "; - - $multiplexingSuccessful = false; if (! $disableMultiplexing && self::isMultiplexingEnabled()) { - try { - $multiplexingSuccessful = self::ensureMultiplexedConnection($server); - if ($multiplexingSuccessful) { - $ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; - } - } catch (\Exception $e) { - // Continue without multiplexing - } + $sshCommand .= self::multiplexingOptions($server); } if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' "; + $sshCommand .= "-o ProxyCommand='cloudflared access ssh --hostname %h' "; } - $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval')); + $sshCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval')); - $delimiter = Hash::make($command); - $delimiter = base64_encode($delimiter); + $delimiter = base64_encode(Hash::make($command)); $command = str_replace($delimiter, '', $command); - $ssh_command .= self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL + return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL .$command.PHP_EOL .$delimiter; + } - return $ssh_command; + private static function multiplexingOptions(Server $server): string + { + return '-o ControlMaster=auto ' + .'-o ControlPath='.self::muxSocket($server).' ' + .'-o ControlPersist='.config('constants.ssh.mux_persist_time').' '; + } + + private static function muxSocket(Server $server): string + { + return '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid; } private static function escapedUserAtHost(Server $server): string @@ -231,7 +147,6 @@ private static function validateSshKey(PrivateKey $privateKey): void $privateKey->storeInFileSystem(); } - // Ensure correct permissions (SSH requires 0600) if (file_exists($keyLocation)) { $currentPerms = fileperms($keyLocation) & 0777; if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) { @@ -262,90 +177,10 @@ private static function getCommonSshOptions(Server $server, string $sshKeyLocati .'-o RequestTTY=no ' .'-o LogLevel=ERROR '; - // Bruh if ($isScp) { - $options .= '-P '.escapeshellarg((string) $server->port).' '; - } else { - $options .= '-p '.escapeshellarg((string) $server->port).' '; + return $options.'-P '.escapeshellarg((string) $server->port).' '; } - return $options; - } - - /** - * Check if the multiplexed connection is healthy by running a test command - */ - public static function isConnectionHealthy(Server $server): bool - { - $sshConfig = self::serverSshConfiguration($server); - $muxSocket = $sshConfig['muxFilename']; - $healthCheckTimeout = config('constants.ssh.mux_health_check_timeout'); - - $healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket "; - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; - } - $healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'"; - - $process = Process::run($healthCommand); - $isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok'); - - return $isHealthy; - } - - /** - * Check if the connection has exceeded its maximum age - */ - public static function isConnectionExpired(Server $server): bool - { - $connectionAge = self::getConnectionAge($server); - $maxAge = config('constants.ssh.mux_max_age'); - - return $connectionAge !== null && $connectionAge > $maxAge; - } - - /** - * Get the age of the current connection in seconds - */ - public static function getConnectionAge(Server $server): ?int - { - $cacheKey = "ssh_mux_connection_time_{$server->uuid}"; - $connectionTime = Cache::get($cacheKey); - - if ($connectionTime === null) { - return null; - } - - return time() - $connectionTime; - } - - /** - * Refresh a multiplexed connection by closing and re-establishing it - */ - public static function refreshMultiplexedConnection(Server $server): bool - { - // Close existing connection - self::removeMuxFile($server); - - // Establish new connection - return self::establishNewMultiplexedConnection($server); - } - - /** - * Store connection metadata when a new connection is established - */ - private static function storeConnectionMetadata(Server $server): void - { - $cacheKey = "ssh_mux_connection_time_{$server->uuid}"; - Cache::put($cacheKey, time(), config('constants.ssh.mux_persist_time') + 300); // Cache slightly longer than persist time - } - - /** - * Clear connection metadata from cache - */ - private static function clearConnectionMetadata(Server $server): void - { - $cacheKey = "ssh_mux_connection_time_{$server->uuid}"; - Cache::forget($cacheKey); + return $options.'-p '.escapeshellarg((string) $server->port).' '; } } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 3a3eeaee6..1f58fbc06 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -17,6 +17,7 @@ use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; +use App\Rules\DockerImageFormat; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Services\DockerImageParser; @@ -1794,8 +1795,8 @@ private function create_application(Request $request, $type) ]))->setStatusCode(201); } elseif ($type === 'dockerimage') { $validationRules = [ - 'docker_registry_image_name' => 'string|required', - 'docker_registry_image_tag' => 'string', + 'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat], + 'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(), 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', ]; $validationRules = array_merge(sharedDataApplications(), $validationRules); diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index dc9b6f5b5..bceef4d39 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -299,6 +299,11 @@ public function database_by_uuid(Request $request) 'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'], 'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'], 'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'], + 'health_check_enabled' => ['type' => 'boolean', 'description' => 'Enable the database healthcheck probe.', 'default' => true], + 'health_check_interval' => ['type' => 'integer', 'description' => 'Healthcheck interval in seconds.', 'minimum' => 1, 'default' => 15], + 'health_check_timeout' => ['type' => 'integer', 'description' => 'Healthcheck timeout in seconds.', 'minimum' => 1, 'default' => 5], + 'health_check_retries' => ['type' => 'integer', 'description' => 'Healthcheck retries count.', 'minimum' => 1, 'default' => 5], + 'health_check_start_period' => ['type' => 'integer', 'description' => 'Healthcheck start period in seconds.', 'minimum' => 0, 'default' => 5], ], ), ) @@ -565,9 +570,17 @@ public function update_by_uuid(Request $request) } break; } + $allowedFields = array_merge($allowedFields, ['health_check_enabled', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period']); + $healthCheckValidator = customApiValidator($request->all(), [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer|min:1', + 'health_check_timeout' => 'integer|min:1', + 'health_check_retries' => 'integer|min:1', + 'health_check_start_period' => 'integer|min:0', + ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); - if ($validator->fails() || ! empty($extraFields)) { - $errors = $validator->errors(); + if ($validator->fails() || $healthCheckValidator->fails() || ! empty($extraFields)) { + $errors = $validator->errors()->merge($healthCheckValidator->errors()); if (! empty($extraFields)) { foreach ($extraFields as $field) { $errors->add($field, 'This field is not allowed.'); diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php new file mode 100644 index 000000000..df5c60d40 --- /dev/null +++ b/app/Http/Controllers/Api/SentinelController.php @@ -0,0 +1,167 @@ +header('Authorization'); + if (! $token) { + auditLogWebhookFailure('sentinel', 'token_missing'); + + return response()->json(['message' => 'Unauthorized'], 401); + } + $naked_token = str_replace('Bearer ', '', $token); + try { + $decrypted = decrypt($naked_token); + $decrypted_token = json_decode($decrypted, true); + } catch (Exception $e) { + auditLogWebhookFailure('sentinel', 'decrypt_failed'); + + return response()->json(['message' => 'Invalid token'], 401); + } + $server_uuid = data_get($decrypted_token, 'server_uuid'); + if (! $server_uuid) { + auditLogWebhookFailure('sentinel', 'invalid_token_payload'); + + return response()->json(['message' => 'Invalid token'], 401); + } + $server = Server::where('uuid', $server_uuid)->first(); + if (! $server) { + auditLogWebhookFailure('sentinel', 'server_not_found', [ + 'server_uuid' => $server_uuid, + ]); + + return response()->json(['message' => 'Server not found'], 404); + } + + if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { + auditLogWebhookFailure('sentinel', 'subscription_unpaid', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + + return response()->json(['message' => 'Unauthorized'], 401); + } + + if ($server->isFunctional() === false) { + auditLogWebhookFailure('sentinel', 'server_not_functional', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + + return response()->json(['message' => 'Server is not functional'], 401); + } + + if ($server->settings->sentinel_token !== $naked_token) { + auditLogWebhookFailure('sentinel', 'token_mismatch', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + + return response()->json(['message' => 'Unauthorized'], 401); + } + $validator = Validator::make($request->all(), [ + 'containers' => ['present', 'array'], + ]); + + if ($validator->fails()) { + return response()->json(serializeApiResponse([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ]), 422); + } + + $data = $request->all(); + + // Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping. + $server->sentinelHeartbeat(); + + if ($this->shouldDispatchUpdate($server, $data)) { + PushServerUpdateJob::dispatch($server, $data); + } + + auditLog('sentinel.metrics_pushed', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + + return response()->json(['message' => 'ok'], 200); + } + + /** + * Decide whether PushServerUpdateJob should be dispatched for this push. + * + * Dispatches when: first push (no cached hash), the container state changed, + * or the force window elapsed. + */ + private function shouldDispatchUpdate(Server $server, array $data): bool + { + $hash = $this->containerStateHash($data); + $hashKey = "sentinel:push-hash:{$server->id}"; + $forceKey = "sentinel:push-force:{$server->id}"; + $lockKey = "sentinel:push-lock:{$server->id}"; + + try { + return Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool { + $cachedHash = Cache::get($hashKey); + $forceActive = Cache::has($forceKey); + + $shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive; + + if ($shouldDispatch) { + // Day-long TTL bounds memory if a server stops pushing entirely. + Cache::put($hashKey, $hash, now()->addDay()); + Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300)); + } + + return $shouldDispatch; + }); + } catch (LockTimeoutException) { + return false; + } + } + + /** + * Build a stable hash of container state. + * + * Covers [name, state] only — metrics, filesystem_usage_root, and + * health_status are excluded on purpose. Disk % churns constantly, and + * health checks can flap between starting/healthy/unhealthy while the + * container lifecycle state remains unchanged. Both would otherwise defeat + * the hash and dispatch DB-heavy PushServerUpdateJob instances too often. + * The force window still refreshes full state periodically. Sorted by name + * so container ordering from Sentinel does not affect the hash. + */ + private function containerStateHash(array $data): string + { + $containers = collect(data_get($data, 'containers', [])) + ->map(fn ($c) => [ + 'name' => data_get($c, 'name'), + 'state' => data_get($c, 'state'), + ]) + ->sortBy('name') + ->values() + ->all(); + + return hash('xxh128', json_encode($containers)); + } +} diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index ee7f25431..d37ba7cee 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -5,6 +5,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; +use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -14,6 +15,7 @@ class Bitbucket extends Controller { use DetectsSkipDeployCommits; + use MatchesManualWebhookApplications; public function manual(Request $request) { @@ -62,8 +64,14 @@ public function manual(Request $request) $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]); $commit = data_get($payload, 'pullrequest.source.commit.hash'); } - $applications = Application::where('git_repository', 'like', "%$full_name%"); - $applications = $applications->where('git_branch', $branch)->get(); + $full_name = $this->manualWebhookRepositoryFullName($full_name); + if ($full_name === null) { + return response([ + 'status' => 'failed', + 'message' => 'Nothing to do. Invalid repository.', + ]); + } + $applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { return response([ 'status' => 'failed', @@ -79,11 +87,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'event' => $x_bitbucket_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Webhook secret not configured.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } @@ -97,11 +101,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'event' => $x_bitbucket_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } @@ -114,11 +114,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'event' => $x_bitbucket_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } diff --git a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php new file mode 100644 index 000000000..0463790eb --- /dev/null +++ b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php @@ -0,0 +1,108 @@ +normalizeManualWebhookRepositoryPath($fullName); + } + + /** + * @return Collection + */ + protected function manualWebhookApplications(Builder $query, string $fullName): Collection + { + return $query->get() + ->filter(fn (Application $application): bool => $this->manualWebhookRepositoryMatches($application->git_repository, $fullName)) + ->values(); + } + + protected function manualWebhookRepositoryMatches(?string $gitRepository, string $fullName): bool + { + $repositoryPath = $this->canonicalManualWebhookRepository($gitRepository); + + if ($repositoryPath === null) { + return false; + } + + // Git hosts (GitHub, GitLab, Gitea, Bitbucket) treat owner/repo names + // case-insensitively, so compare the canonical paths case-insensitively. + return hash_equals(mb_strtolower($fullName), mb_strtolower($repositoryPath)); + } + + /** + * @return array{status: string, message: string} + */ + protected function unauthenticatedManualWebhookFailurePayload(): array + { + return [ + 'status' => 'failed', + 'message' => 'Invalid signature.', + ]; + } + + protected function canonicalManualWebhookRepository(?string $gitRepository): ?string + { + if (! is_string($gitRepository)) { + return null; + } + + $gitRepository = trim($gitRepository); + + if ($gitRepository === '') { + return null; + } + + $path = null; + $parts = parse_url($gitRepository); + + if (is_array($parts) && isset($parts['scheme'])) { + $path = data_get($parts, 'path'); + } elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) { + $path = Str::after($gitRepository, ':'); + // scp-style SSH URLs embed a custom port as "git@host:2222/owner/repo". + // Strip the leading numeric port segment so the path matches the webhook + // payload's owner/repo, consistent with convertGitUrl() in shared.php. + $path = preg_replace('#^\d+/#', '', $path) ?? $path; + } else { + $path = $gitRepository; + } + + if (! is_string($path) || $path === '') { + return null; + } + + return $this->normalizeManualWebhookRepositoryPath($path); + } + + protected function normalizeManualWebhookRepositoryPath(string $path): string + { + $path = trim($path); + $path = strtok($path, '?#') ?: $path; + $path = trim($path, '/'); + $path = preg_replace('/\.git\z/i', '', $path) ?? $path; + + return $path; + } +} diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 64807d694..be064e380 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -5,6 +5,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; +use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -15,6 +16,7 @@ class Gitea extends Controller { use DetectsSkipDeployCommits; + use MatchesManualWebhookApplications; public function manual(Request $request) { @@ -58,15 +60,19 @@ public function manual(Request $request) if (! $branch) { return response('Nothing to do. No branch found in the request.'); } - $applications = Application::where('git_repository', 'like', "%$full_name%"); + $full_name = $this->manualWebhookRepositoryFullName($full_name); + if ($full_name === null) { + return response('Nothing to do. Invalid repository.'); + } + $applications = Application::query(); if ($x_gitea_event === 'push') { - $applications = $applications->where('git_branch', $branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name."); } } if ($x_gitea_event === 'pull_request') { - $applications = $applications->where('git_branch', $base_branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); if ($applications->isEmpty()) { return response("Nothing to do. No applications found with branch '$base_branch'."); } @@ -80,11 +86,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'event' => $x_gitea_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Webhook secret not configured.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } @@ -96,11 +98,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'event' => $x_gitea_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index b0e11f60c..8e3ffcd7c 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; +use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; use App\Jobs\GithubAppPermissionJob; use App\Jobs\ProcessGithubPullRequestWebhook; use App\Models\Application; @@ -11,6 +12,7 @@ use App\Models\PrivateKey; use Exception; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; @@ -18,6 +20,7 @@ class Github extends Controller { use DetectsSkipDeployCommits; + use MatchesManualWebhookApplications; public function manual(Request $request) { @@ -59,6 +62,7 @@ public function manual(Request $request) $before_sha = data_get($payload, 'before'); $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $author_association = data_get($payload, 'pull_request.author_association'); + $is_fork_pull_request = $this->isForkPullRequest($payload); } if (! in_array($x_github_event, ['push', 'pull_request'])) { return response("Nothing to do. Event '$x_github_event' is not supported."); @@ -66,15 +70,19 @@ public function manual(Request $request) if (! $branch) { return response('Nothing to do. No branch found in the request.'); } - $applications = Application::where('git_repository', 'like', "%$full_name%"); + $full_name = $this->manualWebhookRepositoryFullName($full_name); + if ($full_name === null) { + return response('Nothing to do. Invalid repository.'); + } + $applications = Application::query(); if ($x_github_event === 'push') { - $applications = $applications->where('git_branch', $branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name."); } } if ($x_github_event === 'pull_request') { - $applications = $applications->where('git_branch', $base_branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); if ($applications->isEmpty()) { return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'."); } @@ -93,11 +101,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'mode' => 'manual', ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Webhook secret not configured.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } @@ -109,11 +113,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'mode' => 'manual', ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } @@ -223,6 +223,7 @@ public function manual(Request $request) commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), authorAssociation: $author_association, fullName: $full_name, + isForkPullRequest: $is_fork_pull_request ?? false, ); $return_payloads->push([ @@ -304,6 +305,7 @@ public function normal(Request $request) $before_sha = data_get($payload, 'before'); $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $author_association = data_get($payload, 'pull_request.author_association'); + $is_fork_pull_request = $this->isForkPullRequest($payload); } if (! in_array($x_github_event, ['push', 'pull_request'])) { return response("Nothing to do. Event '$x_github_event' is not supported."); @@ -435,6 +437,7 @@ public function normal(Request $request) commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), authorAssociation: $author_association, fullName: $full_name, + isForkPullRequest: $is_fork_pull_request ?? false, ); $return_payloads->push([ @@ -452,55 +455,172 @@ public function normal(Request $request) } } + /** + * Determine whether a pull_request webhook payload originates from a fork. + * + * GitHub's `author_association` is not a reliable trust signal (it grants + * CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork + * detection is gated on whether the PR crosses repository boundaries. + * + * The repository id comparison is the canonical signal; the `head.repo.fork` + * flag and a case-insensitive full_name comparison are fallbacks for payloads + * where the ids are unavailable (e.g. a deleted head repository). + */ + private function isForkPullRequest(mixed $payload): bool + { + $headRepoId = data_get($payload, 'pull_request.head.repo.id'); + $baseRepoId = data_get($payload, 'pull_request.base.repo.id'); + + if ($headRepoId !== null && $baseRepoId !== null) { + return (string) $headRepoId !== (string) $baseRepoId; + } + + if (data_get($payload, 'pull_request.head.repo.fork') === true) { + return true; + } + + $headRepoFullName = data_get($payload, 'pull_request.head.repo.full_name'); + $baseRepoFullName = data_get($payload, 'pull_request.base.repo.full_name'); + + if (is_string($headRepoFullName) && is_string($baseRepoFullName)) { + return Str::lower($headRepoFullName) !== Str::lower($baseRepoFullName); + } + + return false; + } + public function redirect(Request $request) { - try { - $code = $request->get('code'); - $state = $request->get('state'); - $github_app = GithubApp::where('uuid', $state)->firstOrFail(); - $api_url = data_get($github_app, 'api_url'); - $data = Http::withBody(null)->accept('application/vnd.github+json')->post("$api_url/app-manifests/$code/conversions")->throw()->json(); - $id = data_get($data, 'id'); - $slug = data_get($data, 'slug'); - $client_id = data_get($data, 'client_id'); - $client_secret = data_get($data, 'client_secret'); - $private_key = data_get($data, 'pem'); - $webhook_secret = data_get($data, 'webhook_secret'); - $private_key = PrivateKey::create([ - 'name' => "github-app-{$slug}", - 'private_key' => $private_key, - 'team_id' => $github_app->team_id, - 'is_git_related' => true, - ]); - $github_app->name = $slug; - $github_app->app_id = $id; - $github_app->client_id = $client_id; - $github_app->client_secret = $client_secret; - $github_app->webhook_secret = $webhook_secret; - $github_app->private_key_id = $private_key->id; - $github_app->save(); + $code = (string) $request->query('code', ''); + abort_if(blank($code), 422, 'Missing GitHub App manifest code.'); - return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]); - } catch (Exception $e) { - return handleError($e); - } + $github_app = $this->consumeGithubAppSetupState( + request: $request, + state: (string) $request->query('state', ''), + action: 'manifest', + ); + + abort_if($this->githubAppHasManifestCredentials($github_app), 403, 'GitHub App credentials are already configured.'); + + $api_url = data_get($github_app, 'api_url'); + $data = Http::withBody(null) + ->accept('application/vnd.github+json') + ->timeout(10) + ->connectTimeout(5) + ->post("$api_url/app-manifests/$code/conversions") + ->throw() + ->json(); + + $id = data_get($data, 'id'); + $slug = data_get($data, 'slug'); + $client_id = data_get($data, 'client_id'); + $client_secret = data_get($data, 'client_secret'); + $private_key = data_get($data, 'pem'); + $webhook_secret = data_get($data, 'webhook_secret'); + + abort_if(blank($id) || blank($slug) || blank($client_id) || blank($client_secret) || blank($private_key) || blank($webhook_secret), 422, 'GitHub App manifest conversion response is incomplete.'); + + $private_key = PrivateKey::create([ + 'name' => "github-app-{$slug}", + 'private_key' => $private_key, + 'team_id' => $github_app->team_id, + 'is_git_related' => true, + ]); + $github_app->name = $slug; + $github_app->app_id = $id; + $github_app->client_id = $client_id; + $github_app->client_secret = $client_secret; + $github_app->webhook_secret = $webhook_secret; + $github_app->private_key_id = $private_key->id; + $github_app->save(); + + return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]); } public function install(Request $request) { - try { - $installation_id = $request->get('installation_id'); - $source = $request->get('source'); - $setup_action = $request->get('setup_action'); - $github_app = GithubApp::where('uuid', $source)->firstOrFail(); - if ($setup_action === 'install') { - $github_app->installation_id = $installation_id; - $github_app->save(); - } + $source = (string) $request->query('source', ''); + abort_if(blank($source), 404); + $github_app = GithubApp::ownedByCurrentTeam()->where('uuid', $source)->firstOrFail(); + + $setup_action = (string) $request->query('setup_action', ''); + if ($setup_action !== 'install') { return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]); - } catch (Exception $e) { - return handleError($e); + } + + $installation_id = (string) $request->query('installation_id', ''); + abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.'); + + abort_unless( + $this->githubInstallationBelongsToApp($github_app, $installation_id), + 403, + 'GitHub App installation could not be verified.' + ); + + $github_app->installation_id = $installation_id; + $github_app->save(); + + return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]); + } + + /** + * Verify that the given installation id actually belongs to this GitHub App. + * + * The installation id arrives as an untrusted query parameter on an + * unauthenticated-reachable GET callback, so it must be confirmed against + * the GitHub API using the App's own credentials before it is persisted. + */ + private function githubInstallationBelongsToApp(GithubApp $github_app, string $installation_id): bool + { + if (blank($github_app->app_id) || blank($github_app->privateKey?->private_key)) { + return false; + } + + try { + $jwt = generateGithubJwt($github_app); + $response = Http::withHeaders([ + 'Authorization' => "Bearer $jwt", + 'Accept' => 'application/vnd.github+json', + ]) + ->timeout(10) + ->connectTimeout(5) + ->get("{$github_app->api_url}/app/installations/{$installation_id}"); + + return $response->successful() + && (string) data_get($response->json(), 'app_id') === (string) $github_app->app_id; + } catch (\Throwable) { + return false; } } + + private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp + { + abort_if(blank($state), 404); + + $payload = Cache::pull($this->githubAppSetupStateCacheKey($state)); + abort_unless(is_array($payload), 404); + abort_unless(data_get($payload, 'action') === $action, 404); + + $team_id = $request->user()?->currentTeam()?->id; + abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403); + + return GithubApp::whereKey(data_get($payload, 'github_app_id')) + ->where('team_id', data_get($payload, 'team_id')) + ->firstOrFail(); + } + + private function githubAppSetupStateCacheKey(string $state): string + { + return 'github-app-setup-state:'.hash('sha256', $state); + } + + private function githubAppHasManifestCredentials(GithubApp $github_app): bool + { + return filled($github_app->app_id) + || filled($github_app->client_id) + || filled($github_app->client_secret) + || filled($github_app->webhook_secret) + || filled($github_app->private_key_id); + } } diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 205bede8f..231a0b6e5 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -5,6 +5,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; +use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -15,6 +16,7 @@ class Gitlab extends Controller { use DetectsSkipDeployCommits; + use MatchesManualWebhookApplications; public function manual(Request $request) { @@ -85,9 +87,18 @@ public function manual(Request $request) return response($return_payloads); } } - $applications = Application::where('git_repository', 'like', "%$full_name%"); + $full_name = $this->manualWebhookRepositoryFullName($full_name); + if ($full_name === null) { + $return_payloads->push([ + 'status' => 'failed', + 'message' => 'Nothing to do. Invalid repository.', + ]); + + return response($return_payloads); + } + $applications = Application::query(); if ($x_gitlab_event === 'push') { - $applications = $applications->where('git_branch', $branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { $return_payloads->push([ 'status' => 'failed', @@ -98,7 +109,7 @@ public function manual(Request $request) } } if ($x_gitlab_event === 'merge_request') { - $applications = $applications->where('git_branch', $base_branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); if ($applications->isEmpty()) { $return_payloads->push([ 'status' => 'failed', @@ -117,11 +128,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'event' => $x_gitlab_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Webhook secret not configured.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } @@ -132,11 +139,7 @@ public function manual(Request $request) 'repository' => $full_name ?? null, 'event' => $x_gitlab_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index a584bc111..02a49aaa8 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -12,6 +12,7 @@ use App\Http\Middleware\DecideWhatToDoWithUser; use App\Http\Middleware\EncryptCookies; use App\Http\Middleware\EnsureMcpEnabled; +use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember; use App\Http\Middleware\PreventRequestsDuringMaintenance; use App\Http\Middleware\RedirectIfAuthenticated; use App\Http\Middleware\TrimStrings; @@ -104,6 +105,7 @@ class Kernel extends HttpKernel 'ability' => CheckForAnyAbility::class, 'api.ability' => ApiAbility::class, 'api.sensitive' => ApiSensitiveData::class, + 'api.token.team' => EnsureTokenBelongsToCurrentTeamMember::class, 'can.create.resources' => CanCreateResources::class, 'can.update.resource' => CanUpdateResource::class, 'can.access.terminal' => CanAccessTerminal::class, diff --git a/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php b/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php new file mode 100644 index 000000000..7c858b38b --- /dev/null +++ b/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php @@ -0,0 +1,37 @@ +user(); + $token = $user?->currentAccessToken(); + $teamId = $token?->team_id; + + if (! $user || ! $token || is_null($teamId)) { + return response()->json(['message' => 'Invalid token.'], 401); + } + + $team = $user->teams() + ->where('teams.id', $teamId) + ->first(); + + if (! $team) { + return response()->json(['message' => 'Invalid token.'], 401); + } + + $role = $team->pivot?->role; + if (($token->can('root') || $token->can('write') || $token->can('write:sensitive')) + && ! in_array($role, ['admin', 'owner'], true)) { + return response()->json(['message' => 'Missing required team role.'], 403); + } + + return $next($request); + } +} diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 2e43456b8..a1478aaff 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -197,7 +197,7 @@ public function tags() public function __construct(public int $application_deployment_queue_id) { - $this->onQueue('high'); + $this->onQueue(deployment_queue()); $this->application_deployment_queue = ApplicationDeploymentQueue::find($this->application_deployment_queue_id); $this->nixpacks_plan_json = collect([]); @@ -220,6 +220,7 @@ public function __construct(public int $application_deployment_queue_id) $this->restart_only = $this->restart_only && $this->application->build_pack !== 'dockerimage' && $this->application->build_pack !== 'dockerfile'; $this->only_this_server = $this->application_deployment_queue->only_this_server; $this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag; + $this->validateDockerRegistryImageConfiguration(); $this->git_type = data_get($this->application_deployment_queue, 'git_type'); @@ -1106,7 +1107,7 @@ private function push_to_docker_registry() 'hidden' => true, ], ); - if ($this->application->docker_registry_image_tag) { + if ($this->shouldPushDockerRegistryImageTag()) { // Tag image with docker_registry_image_tag $this->application_deployment_queue->addLogEntry("Tagging and pushing image with {$this->application->docker_registry_image_tag} tag."); $this->execute_remote_command( @@ -1130,6 +1131,30 @@ private function push_to_docker_registry() } } + private function shouldPushDockerRegistryImageTag(): bool + { + if (blank($this->application->docker_registry_image_tag)) { + return false; + } + + return $this->pull_request_id === 0; + } + + private function validateDockerRegistryImageConfiguration(): void + { + if (! ValidationPatterns::isValidDockerImageName($this->application->docker_registry_image_name)) { + throw new DeploymentException('Docker registry image name contains invalid characters.'); + } + + if (! ValidationPatterns::isValidDockerImageTag($this->application->docker_registry_image_tag)) { + throw new DeploymentException('Docker registry image tag contains invalid characters.'); + } + + if (! ValidationPatterns::isValidDockerImageTag($this->dockerImagePreviewTag)) { + throw new DeploymentException('Docker registry preview image tag contains invalid characters.'); + } + } + private function generate_image_names() { if ($this->application->dockerfile) { @@ -1293,12 +1318,8 @@ private function generate_runtime_environment_variables() $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id'); } if ($this->build_pack === 'dockercompose') { - $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { - return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_'); - }); - $sorted_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) { - return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_'); - }); + $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); + $sorted_environment_variables_preview = $sorted_environment_variables_preview->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); } $ports = $this->application->main_port(); $coolify_envs = $this->generate_coolify_env_variables(); @@ -1451,6 +1472,15 @@ private function generate_runtime_environment_variables() return $envs; } + private function isGeneratedDockerComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool + { + $key = str($environmentVariable->key); + + return $key->startsWith('SERVICE_FQDN_') + || $key->startsWith('SERVICE_URL_') + || $key->startsWith('SERVICE_NAME_'); + } + private function save_runtime_environment_variables() { // This method saves the .env file with ALL runtime variables @@ -1666,11 +1696,9 @@ private function generate_buildtime_environment_variables() ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->get(); - // For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these + // For Docker Compose, filter out generated SERVICE_* variables as we generate these if ($this->build_pack === 'dockercompose') { - $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { - return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); - }); + $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); } foreach ($sorted_environment_variables as $env) { @@ -1719,11 +1747,9 @@ private function generate_buildtime_environment_variables() ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->get(); - // For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these with PR-specific values + // For Docker Compose, filter out generated SERVICE_* variables as we generate these with PR-specific values if ($this->build_pack === 'dockercompose') { - $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { - return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); - }); + $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); } foreach ($sorted_environment_variables as $env) { @@ -2222,11 +2248,22 @@ private function set_coolify_variables() } } if (isset($this->application->git_branch)) { - $this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} "; + $this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' '; } $this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} "; } + private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string + { + $sshCommand = "ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"; + + if ($identityFile !== null) { + $sshCommand .= " -i {$identityFile}"; + } + + return 'GIT_SSH_COMMAND="'.$sshCommand.'" git ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef); + } + private function check_git_if_build_needed() { if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) { @@ -2272,7 +2309,7 @@ private function check_git_if_build_needed() executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), ], [ - executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), + executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef, '/root/.ssh/id_rsa')), 'hidden' => true, 'save' => 'git_commit_sha', ] @@ -2280,7 +2317,7 @@ private function check_git_if_build_needed() } else { $this->execute_remote_command( [ - executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), + executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef)), 'hidden' => true, 'save' => 'git_commit_sha', ], @@ -3019,6 +3056,10 @@ private function generate_env_variables() ->where('is_buildtime', true) ->get(); + if ($this->build_pack === 'dockercompose') { + $envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); + } + foreach ($envs as $env) { $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { @@ -3031,6 +3072,10 @@ private function generate_env_variables() ->where('is_buildtime', true) ->get(); + if ($this->build_pack === 'dockercompose') { + $envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); + } + foreach ($envs as $env) { $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { diff --git a/app/Jobs/CleanupStaleMultiplexedConnections.php b/app/Jobs/CleanupStaleMultiplexedConnections.php deleted file mode 100644 index 6d49bee4b..000000000 --- a/app/Jobs/CleanupStaleMultiplexedConnections.php +++ /dev/null @@ -1,82 +0,0 @@ -cleanupStaleConnections(); - $this->cleanupNonExistentServerConnections(); - } - - private function cleanupStaleConnections() - { - $muxFiles = Storage::disk('ssh-mux')->files(); - - foreach ($muxFiles as $muxFile) { - $serverUuid = $this->extractServerUuidFromMuxFile($muxFile); - $server = Server::where('uuid', $serverUuid)->first(); - - if (! $server) { - $this->removeMultiplexFile($muxFile); - - continue; - } - - $muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}"; - $checkCommand = "ssh -O check -o ControlPath={$muxSocket} {$server->user}@{$server->ip} 2>/dev/null"; - $checkProcess = Process::run($checkCommand); - - if ($checkProcess->exitCode() !== 0) { - $this->removeMultiplexFile($muxFile); - } else { - $muxContent = Storage::disk('ssh-mux')->get($muxFile); - $establishedAt = Carbon::parse(substr($muxContent, 37)); - $expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time')); - - if (Carbon::now()->isAfter($expirationTime)) { - $this->removeMultiplexFile($muxFile); - } - } - } - } - - private function cleanupNonExistentServerConnections() - { - $muxFiles = Storage::disk('ssh-mux')->files(); - $existingServerUuids = Server::pluck('uuid')->toArray(); - - foreach ($muxFiles as $muxFile) { - $serverUuid = $this->extractServerUuidFromMuxFile($muxFile); - if (! in_array($serverUuid, $existingServerUuids)) { - $this->removeMultiplexFile($muxFile); - } - } - } - - private function extractServerUuidFromMuxFile($muxFile) - { - return substr($muxFile, 4); - } - - private function removeMultiplexFile($muxFile) - { - $muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}"; - $closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null"; - Process::run($closeCommand); - Storage::disk('ssh-mux')->delete($muxFile); - } -} diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 207191cbd..64e900b49 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -77,7 +77,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue public function __construct(public ScheduledDatabaseBackup $backup) { - $this->onQueue('high'); + $this->onQueue(crons_queue()); $this->timeout = $backup->timeout ?? 3600; } @@ -668,12 +668,14 @@ private function calculate_size() private function upload_to_s3(): void { if (is_null($this->s3)) { + $previousS3StorageId = $this->backup->s3_storage_id; + $this->backup->update([ 'save_s3' => false, 's3_storage_id' => null, ]); - throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.'); + throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($previousS3StorageId ?? 'null').'). S3 backup has been disabled for this schedule.'); } try { diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php index 54e386676..141351784 100644 --- a/app/Jobs/ProcessGithubPullRequestWebhook.php +++ b/app/Jobs/ProcessGithubPullRequestWebhook.php @@ -39,6 +39,7 @@ public function __construct( public string $commitSha, public ?string $authorAssociation, public string $fullName, + public bool $isForkPullRequest = false, ) { $this->onQueue('high'); } @@ -92,7 +93,17 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp // Check if PR deployments from public contributors are restricted if (! $application->settings->is_pr_deployments_public_enabled) { - $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']; + // Fork PRs carry untrusted code from a repository outside our control. + // GitHub's author_association cannot be trusted to gate these (it grants + // CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork + // PRs are never deployed automatically when public previews are off. + if ($this->isForkPullRequest) { + return; + } + + // Same-repo (non-fork) branch PRs require push access to the base repo, + // so only trusted associations are allowed to trigger a deployment. + $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR']; if (! in_array($this->authorAssociation, $trustedAssociations)) { return; } diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index b1a12ae2a..62e98934e 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -13,6 +13,16 @@ use App\Models\Server; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDocker; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneMariadb; +use App\Models\StandaloneMongodb; +use App\Models\StandaloneMysql; +use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; +use App\Models\SwarmDocker; use App\Notifications\Container\ContainerRestarted; use App\Services\ContainerStatusAggregator; use App\Traits\CalculatesExcludedStatus; @@ -25,6 +35,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Laravel\Horizon\Contracts\Silenced; class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced @@ -46,6 +57,18 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced public Collection $services; + public Collection $applicationsById; + + public Collection $previewsByKey; + + public Collection $databasesByUuid; + + public Collection $servicesById; + + public Collection $serviceApplicationsById; + + public Collection $serviceDatabasesById; + public Collection $allApplicationIds; public Collection $allDatabaseUuids; @@ -78,6 +101,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced public bool $foundLogDrainContainer = false; + private ?array $cachedDestinationIds = null; + public function middleware(): array { return [(new WithoutOverlapping('push-server-update-'.$this->server->uuid))->expireAfter(30)->dontRelease()]; @@ -103,6 +128,12 @@ public function __construct(public Server $server, public $data) $this->allTcpProxyUuids = collect(); $this->allServiceApplicationIds = collect(); $this->allServiceDatabaseIds = collect(); + $this->applicationsById = collect(); + $this->previewsByKey = collect(); + $this->databasesByUuid = collect(); + $this->servicesById = collect(); + $this->serviceApplicationsById = collect(); + $this->serviceDatabasesById = collect(); } public function handle() @@ -120,6 +151,16 @@ public function handle() $this->allTcpProxyUuids ??= collect(); $this->allServiceApplicationIds ??= collect(); $this->allServiceDatabaseIds ??= collect(); + $this->applicationsById ??= collect(); + $this->previewsByKey ??= collect(); + $this->databasesByUuid ??= collect(); + $this->servicesById ??= collect(); + $this->serviceApplicationsById ??= collect(); + $this->serviceDatabasesById ??= collect(); + + // Eager-load relations the job touches repeatedly to avoid lazy-load queries + // (settings: disk threshold, isProxyShouldRun, isLogDrainEnabled; team: notifications). + $this->server->loadMissing(['settings', 'team']); // TODO: Swarm is not supported yet if (! $this->data) { @@ -127,30 +168,40 @@ public function handle() } $data = collect($this->data); - $this->server->sentinelHeartbeat(); - + // Heartbeat is updated by SentinelController on every push, before dispatch. $this->containers = collect(data_get($data, 'containers')); $filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage'); - // Only dispatch storage check when disk percentage actually changes + // Only dispatch the storage check when disk usage is at/above the notification + // threshold AND the value changed. Below the threshold ServerStorageCheckJob + // has nothing to do (it only sends a HighDiskUsage notification), so dispatching + // it is wasted work — and most servers sit well below the threshold. + $diskThreshold = data_get($this->server, 'settings.server_disk_usage_notification_threshold', 80); $storageCacheKey = 'storage-check:'.$this->server->id; $lastPercentage = Cache::get($storageCacheKey); - if ($lastPercentage === null || (string) $lastPercentage !== (string) $filesystemUsageRoot) { + if ($filesystemUsageRoot !== null + && $filesystemUsageRoot >= $diskThreshold + && (string) $lastPercentage !== (string) $filesystemUsageRoot) { Cache::put($storageCacheKey, $filesystemUsageRoot, 600); ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot); + } elseif ($filesystemUsageRoot !== null && $filesystemUsageRoot < $diskThreshold) { + Cache::forget($storageCacheKey); } if ($this->containers->isEmpty()) { return; } - $this->applications = $this->server->applications(); - $this->databases = $this->server->databases(); - $this->previews = $this->server->previews(); - // Eager load service applications and databases to avoid N+1 queries - $this->services = $this->server->services() - ->with(['applications:id,service_id', 'databases:id,service_id']) - ->get(); + $this->applications = $this->loadApplications(); + $this->databases = $this->loadDatabases(); + $this->previews = $this->loadPreviews(); + $this->services = $this->loadServices(); + $this->applicationsById = $this->applications->keyBy(fn ($application) => (string) $application->id); + $this->previewsByKey = $this->previews->keyBy(fn ($preview) => $preview->application_id.':'.$preview->pull_request_id); + $this->databasesByUuid = $this->databases->keyBy('uuid'); + $this->servicesById = $this->services->keyBy(fn ($service) => (string) $service->id); + $this->serviceApplicationsById = $this->services->flatMap(fn ($service) => $service->applications)->keyBy(fn ($application) => (string) $application->id); + $this->serviceDatabasesById = $this->services->flatMap(fn ($service) => $service->databases)->keyBy(fn ($database) => (string) $database->id); $this->allApplicationIds = $this->applications->filter(function ($application) { return $application->additional_servers_count === 0; @@ -163,9 +214,8 @@ public function handle() }); $this->allDatabaseUuids = $this->databases->pluck('uuid'); $this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid'); - // Use eager-loaded relationships instead of querying in loop - $this->allServiceApplicationIds = $this->services->flatMap(fn ($service) => $service->applications->pluck('id')); - $this->allServiceDatabaseIds = $this->services->flatMap(fn ($service) => $service->databases->pluck('id')); + $this->allServiceApplicationIds = $this->serviceApplicationsById->keys(); + $this->allServiceDatabaseIds = $this->serviceDatabasesById->keys(); foreach ($this->containers as $container) { $containerStatus = data_get($container, 'state', 'exited'); @@ -279,6 +329,151 @@ public function handle() $this->checkLogDrainContainer(); } + private function loadApplications(): Collection + { + [$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds(); + + $applications = ($standaloneDockerIds->isNotEmpty() || $swarmDockerIds->isNotEmpty()) + ? Application::withoutGlobalScope('withRelations') + ->select([ + 'id', + 'uuid', + 'name', + 'status', + 'build_pack', + 'docker_compose_raw', + 'destination_id', + 'destination_type', + 'last_online_at', + ]) + ->withCount('additional_servers') + ->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds)) + ->get() + : collect(); + + $additionalApplicationIds = DB::table('additional_destinations') + ->where('server_id', $this->server->id) + ->pluck('application_id'); + + if ($additionalApplicationIds->isNotEmpty()) { + $applications = $applications->concat( + Application::withoutGlobalScope('withRelations') + ->select([ + 'id', + 'uuid', + 'name', + 'status', + 'build_pack', + 'docker_compose_raw', + 'destination_id', + 'destination_type', + 'last_online_at', + ]) + ->withCount('additional_servers') + ->whereIn('id', $additionalApplicationIds) + ->get() + ); + } + + return $applications->unique('id')->values(); + } + + private function loadPreviews(): Collection + { + $applicationIds = $this->applications->pluck('id'); + + if ($applicationIds->isEmpty()) { + return collect(); + } + + return ApplicationPreview::query() + ->select([ + 'id', + 'application_id', + 'pull_request_id', + 'status', + 'last_online_at', + ]) + ->whereIn('application_id', $applicationIds) + ->get(); + } + + private function loadServices(): Collection + { + return $this->server->services() + ->select([ + 'id', + 'server_id', + 'uuid', + 'docker_compose_raw', + ]) + ->with([ + 'applications:id,service_id,status,last_online_at', + 'databases:id,service_id,status,last_online_at,is_public,name', + ]) + ->get(); + } + + private function loadDatabases(): Collection + { + [$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds(); + if ($standaloneDockerIds->isEmpty() && $swarmDockerIds->isEmpty()) { + return collect(); + } + $databaseColumns = [ + 'id', + 'uuid', + 'name', + 'status', + 'is_public', + 'destination_id', + 'destination_type', + 'last_online_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + ]; + + return collect([ + StandalonePostgresql::class, + StandaloneRedis::class, + StandaloneMongodb::class, + StandaloneMysql::class, + StandaloneMariadb::class, + StandaloneKeydb::class, + StandaloneDragonfly::class, + StandaloneClickhouse::class, + ])->flatMap(function (string $databaseClass) use ($databaseColumns, $standaloneDockerIds, $swarmDockerIds) { + return $databaseClass::query() + ->select($databaseColumns) + ->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds)) + ->get(); + })->filter(fn ($database) => data_get($database, 'name') !== 'coolify-db')->values(); + } + + private function serverDestinationIds(): array + { + if ($this->cachedDestinationIds !== null) { + return $this->cachedDestinationIds; + } + + return $this->cachedDestinationIds = [ + StandaloneDocker::where('server_id', $this->server->id)->pluck('id'), + SwarmDocker::where('server_id', $this->server->id)->pluck('id'), + ]; + } + + private function scopeDestination($query, Collection $standaloneDockerIds, Collection $swarmDockerIds): void + { + $query->where(function ($query) use ($standaloneDockerIds) { + $query->where('destination_type', StandaloneDocker::class) + ->whereIn('destination_id', $standaloneDockerIds); + })->orWhere(function ($query) use ($swarmDockerIds) { + $query->where('destination_type', SwarmDocker::class) + ->whereIn('destination_id', $swarmDockerIds); + }); + } + private function aggregateMultiContainerStatuses() { if ($this->applicationContainerStatuses->isEmpty()) { @@ -286,7 +481,7 @@ private function aggregateMultiContainerStatuses() } foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) { - $application = $this->applications->where('id', $applicationId)->first(); + $application = $this->applicationsById->get((string) $applicationId); if (! $application) { continue; } @@ -307,8 +502,6 @@ private function aggregateMultiContainerStatuses() if ($aggregatedStatus && $application->status !== $aggregatedStatus) { $application->status = $aggregatedStatus; $application->save(); - } elseif ($aggregatedStatus) { - $application->update(['last_online_at' => now()]); } continue; @@ -323,8 +516,6 @@ private function aggregateMultiContainerStatuses() if ($aggregatedStatus && $application->status !== $aggregatedStatus) { $application->status = $aggregatedStatus; $application->save(); - } elseif ($aggregatedStatus) { - $application->update(['last_online_at' => now()]); } } } @@ -343,7 +534,7 @@ private function aggregateServiceContainerStatuses() continue; } - $service = $this->services->where('id', $serviceId)->first(); + $service = $this->servicesById->get((string) $serviceId); if (! $service) { continue; } @@ -351,9 +542,9 @@ private function aggregateServiceContainerStatuses() // Get the service sub-resource (ServiceApplication or ServiceDatabase) $subResource = null; if ($subType === 'application') { - $subResource = $service->applications->where('id', $subId)->first(); + $subResource = $this->serviceApplicationsById->get((string) $subId); } elseif ($subType === 'database') { - $subResource = $service->databases->where('id', $subId)->first(); + $subResource = $this->serviceDatabasesById->get((string) $subId); } if (! $subResource) { @@ -375,8 +566,6 @@ private function aggregateServiceContainerStatuses() if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { $subResource->status = $aggregatedStatus; $subResource->save(); - } elseif ($aggregatedStatus) { - $subResource->update(['last_online_at' => now()]); } continue; @@ -392,39 +581,31 @@ private function aggregateServiceContainerStatuses() if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { $subResource->status = $aggregatedStatus; $subResource->save(); - } elseif ($aggregatedStatus) { - $subResource->update(['last_online_at' => now()]); } } } private function updateApplicationStatus(string $applicationId, string $containerStatus) { - $application = $this->applications->where('id', $applicationId)->first(); + $application = $this->applicationsById->get((string) $applicationId); if (! $application) { return; } if ($application->status !== $containerStatus) { $application->status = $containerStatus; $application->save(); - } else { - $application->update(['last_online_at' => now()]); } } private function updateApplicationPreviewStatus(string $applicationId, string $pullRequestId, string $containerStatus) { - $application = $this->previews->where('application_id', $applicationId) - ->where('pull_request_id', $pullRequestId) - ->first(); + $application = $this->previewsByKey->get($applicationId.':'.$pullRequestId); if (! $application) { return; } if ($application->status !== $containerStatus) { $application->status = $containerStatus; $application->save(); - } else { - $application->update(['last_online_at' => now()]); } } @@ -472,9 +653,7 @@ private function updateNotFoundApplicationPreviewStatus() $applicationId = $parts[0]; $pullRequestId = $parts[1]; - $applicationPreview = $this->previews->where('application_id', $applicationId) - ->where('pull_request_id', $pullRequestId) - ->first(); + $applicationPreview = $this->previewsByKey->get($applicationId.':'.$pullRequestId); if ($applicationPreview && ! str($applicationPreview->status)->startsWith('exited')) { $previewIdsToUpdate->push($applicationPreview->id); @@ -500,11 +679,11 @@ private function updateProxyStatus() } catch (\Throwable $e) { } } else { - // Connect proxy to networks periodically (every 10 min) to avoid excessive job dispatches. + // Connect proxy to networks periodically as a safety net to avoid excessive job dispatches. // On-demand triggers (new network, service deploy) use dispatchSync() and bypass this. $proxyCacheKey = 'connect-proxy:'.$this->server->id; if (! Cache::has($proxyCacheKey)) { - Cache::put($proxyCacheKey, true, 600); + Cache::put($proxyCacheKey, true, config('constants.proxy.connect_networks_interval_seconds', 3600)); ConnectProxyToNetworksJob::dispatch($this->server); } } @@ -513,15 +692,13 @@ private function updateProxyStatus() private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false) { - $database = $this->databases->where('uuid', $databaseUuid)->first(); + $database = $this->databasesByUuid->get($databaseUuid); if (! $database) { return; } if ($database->status !== $containerStatus) { $database->status = $containerStatus; $database->save(); - } else { - $database->update(['last_online_at' => now()]); } if ($this->isRunning($containerStatus) && $tcpProxy) { $tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) { @@ -556,7 +733,7 @@ private function updateNotFoundDatabaseStatus() } $notFoundDatabaseUuids->each(function ($databaseUuid) { - $database = $this->databases->where('uuid', $databaseUuid)->first(); + $database = $this->databasesByUuid->get($databaseUuid); if ($database) { if (! str($database->status)->startsWith('exited')) { $database->update([ diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index 71829ea41..e7a21949c 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -6,14 +6,15 @@ use App\Models\ScheduledTask; use App\Models\Server; use App\Models\Team; +use Cron\CronExpression; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Carbon; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Redis; @@ -22,6 +23,8 @@ class ScheduledJobManager implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + private const CHUNK_SIZE = 100; + /** * The time when this job execution started. * Used to ensure all scheduled items are evaluated against the same point in time. @@ -37,17 +40,7 @@ class ScheduledJobManager implements ShouldQueue */ public function __construct() { - $this->onQueue($this->determineQueue()); - } - - private function determineQueue(): string - { - $preferredQueue = 'crons'; - $fallbackQueue = 'high'; - - $configuredQueues = explode(',', env('HORIZON_QUEUES', 'high,default')); - - return in_array($preferredQueue, $configuredQueues) ? $preferredQueue : $fallbackQueue; + $this->onQueue(crons_queue()); } /** @@ -106,21 +99,11 @@ public function handle(): void 'execution_time' => $this->executionTime->toIso8601String(), ]); - // Process backups - don't let failures stop task processing + // Process scheduled backups and tasks together so neither type starves the other. try { - $this->processScheduledBackups(); + $this->processScheduledBackupsAndTasks(); } catch (\Exception $e) { - Log::channel('scheduled-errors')->error('Failed to process scheduled backups', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - } - - // Process tasks - don't let failures stop the job manager - try { - $this->processScheduledTasks(); - } catch (\Exception $e) { - Log::channel('scheduled-errors')->error('Failed to process scheduled tasks', [ + Log::channel('scheduled-errors')->error('Failed to process scheduled backups and tasks', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); @@ -151,125 +134,211 @@ public function handle(): void } } - private function processScheduledBackups(): void + private function processScheduledBackupsAndTasks(): void { - $backups = ScheduledDatabaseBackup::with(['database']) + $lastBackupId = 0; + $lastTaskId = 0; + + do { + $backups = $this->scheduledBackupQuery($lastBackupId)->get(); + $tasks = $this->scheduledTaskQuery($lastTaskId)->get(); + + if ($backups->isNotEmpty()) { + $lastBackupId = $backups->last()->id; + } + + if ($tasks->isNotEmpty()) { + $lastTaskId = $tasks->last()->id; + } + + $this->processInterleavedDueSchedules( + $this->dueScheduledBackups($backups), + $this->dueScheduledTasks($tasks), + ); + } while ($backups->isNotEmpty() || $tasks->isNotEmpty()); + } + + /** + * @param array $dueBackups + * @param array $dueTasks + */ + private function processInterleavedDueSchedules(array $dueBackups, array $dueTasks): void + { + $maxCount = max(count($dueBackups), count($dueTasks)); + + for ($index = 0; $index < $maxCount; $index++) { + if (isset($dueBackups[$index])) { + $this->processScheduledBackup($dueBackups[$index]['backup'], $dueBackups[$index]['server']); + } + + if (isset($dueTasks[$index])) { + $this->processScheduledTask($dueTasks[$index]['task'], $dueTasks[$index]['server']); + } + } + } + + private function scheduledBackupQuery(int $lastBackupId): Builder + { + return ScheduledDatabaseBackup::with(['database', 'team.subscription']) ->where('enabled', true) - ->get(); + ->where('id', '>', $lastBackupId) + ->orderBy('id') + ->limit(self::CHUNK_SIZE); + } + + private function scheduledTaskQuery(int $lastTaskId): Builder + { + return ScheduledTask::with([ + 'service.destination.server.settings', + 'service.destination.server.team.subscription', + 'application.destination.server.settings', + 'application.destination.server.team.subscription', + ]) + ->where('enabled', true) + ->where('id', '>', $lastTaskId) + ->orderBy('id') + ->limit(self::CHUNK_SIZE); + } + + /** + * @param iterable $backups + * @return array + */ + private function dueScheduledBackups(iterable $backups): array + { + $dueBackups = []; foreach ($backups as $backup) { try { $server = $backup->server(); - $skipReason = $this->getBackupSkipReason($backup, $server); - if ($skipReason !== null) { - $this->skippedCount++; - $this->logSkip('backup', $skipReason, [ - 'backup_id' => $backup->id, - 'database_id' => $backup->database_id, - 'database_type' => $backup->database_type, - 'team_id' => $backup->team_id ?? null, - ]); + + if (blank(data_get($backup, 'database')) || blank($server)) { + $this->processScheduledBackup($backup, $server); continue; } - $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); - - if (validate_timezone($serverTimezone) === false) { - $serverTimezone = config('app.timezone'); - } - - $frequency = $backup->frequency; - if (isset(VALID_CRON_STRINGS[$frequency])) { - $frequency = VALID_CRON_STRINGS[$frequency]; - } - - if (shouldRunCronNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}", $this->executionTime)) { - DatabaseBackupJob::dispatch($backup); - $this->dispatchedCount++; - Log::channel('scheduled')->info('Backup dispatched', [ - 'backup_id' => $backup->id, - 'database_id' => $backup->database_id, - 'database_type' => $backup->database_type, - 'team_id' => $backup->team_id ?? null, - 'server_id' => $server->id, - ]); + if ($this->isDueCandidateBeforeExpensiveChecks($backup->frequency, $server, "scheduled-backup:{$backup->id}")) { + $dueBackups[] = [ + 'backup' => $backup, + 'server' => $server, + ]; } } catch (\Exception $e) { - Log::channel('scheduled-errors')->error('Error processing backup', [ + Log::channel('scheduled-errors')->error('Error prechecking backup', [ 'backup_id' => $backup->id, 'error' => $e->getMessage(), ]); } } + + return $dueBackups; } - private function processScheduledTasks(): void + /** + * @param iterable $tasks + * @return array + */ + private function dueScheduledTasks(iterable $tasks): array { - $tasks = ScheduledTask::with(['service', 'application']) - ->where('enabled', true) - ->get(); + $dueTasks = []; foreach ($tasks as $task) { try { $server = $task->server(); - // Phase 1: Critical checks (always — cheap, handles orphans and infra issues) - $criticalSkip = $this->getTaskCriticalSkipReason($task, $server); - if ($criticalSkip !== null) { - $this->skippedCount++; - $this->logSkip('task', $criticalSkip, [ - 'task_id' => $task->id, - 'task_name' => $task->name, - 'team_id' => $server?->team_id, - ]); + if (blank($server) || (! $task->service && ! $task->application)) { + $this->processScheduledTask($task, $server); continue; } - $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); - - if (validate_timezone($serverTimezone) === false) { - $serverTimezone = config('app.timezone'); + if ($this->isDueCandidateBeforeExpensiveChecks($task->frequency, $server, "scheduled-task:{$task->id}")) { + $dueTasks[] = [ + 'task' => $task, + 'server' => $server, + ]; } - - $frequency = $task->frequency; - if (isset(VALID_CRON_STRINGS[$frequency])) { - $frequency = VALID_CRON_STRINGS[$frequency]; - } - - if (! shouldRunCronNow($frequency, $serverTimezone, "scheduled-task:{$task->id}", $this->executionTime)) { - continue; - } - - // Phase 2: Runtime checks (only when cron is due — avoids noise for stopped resources) - $runtimeSkip = $this->getTaskRuntimeSkipReason($task); - if ($runtimeSkip !== null) { - $this->skippedCount++; - $this->logSkip('task', $runtimeSkip, [ - 'task_id' => $task->id, - 'task_name' => $task->name, - 'team_id' => $server->team_id, - ]); - - continue; - } - - ScheduledTaskJob::dispatch($task); - $this->dispatchedCount++; - Log::channel('scheduled')->info('Task dispatched', [ - 'task_id' => $task->id, - 'task_name' => $task->name, - 'team_id' => $server->team_id, - 'server_id' => $server->id, - ]); } catch (\Exception $e) { - Log::channel('scheduled-errors')->error('Error processing task', [ + Log::channel('scheduled-errors')->error('Error prechecking task', [ 'task_id' => $task->id, 'error' => $e->getMessage(), ]); } } + + return $dueTasks; + } + + private function processScheduledBackup(ScheduledDatabaseBackup $backup, ?Server $precheckedServer = null): void + { + try { + $server = $precheckedServer ?? $backup->server(); + $skipReason = $this->getBackupSkipReason($backup, $server); + if ($skipReason !== null) { + $this->skippedCount++; + $this->logBackupSkip($backup, $skipReason); + + return; + } + + if ($this->shouldDispatch($backup->frequency, $server, "scheduled-backup:{$backup->id}")) { + DatabaseBackupJob::dispatch($backup); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Backup dispatched', [ + 'backup_id' => $backup->id, + 'database_id' => $backup->database_id, + 'database_type' => $backup->database_type, + 'team_id' => $backup->team_id ?? null, + 'server_id' => $server->id, + ]); + } + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Error processing backup', [ + 'backup_id' => $backup->id, + 'error' => $e->getMessage(), + ]); + } + } + + private function processScheduledTask(ScheduledTask $task, ?Server $precheckedServer = null): void + { + try { + $server = $precheckedServer ?? $task->server(); + $criticalSkip = $this->getTaskCriticalSkipReason($task, $server); + if ($criticalSkip !== null) { + $this->skippedCount++; + $this->logTaskSkip($task, $criticalSkip, $server); + + return; + } + + if (! $this->shouldDispatch($task->frequency, $server, "scheduled-task:{$task->id}")) { + return; + } + + $runtimeSkip = $this->getTaskRuntimeSkipReason($task); + if ($runtimeSkip !== null) { + $this->skippedCount++; + $this->logTaskSkip($task, $runtimeSkip, $server); + + return; + } + + ScheduledTaskJob::dispatch($task); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Task dispatched', [ + 'task_id' => $task->id, + 'task_name' => $task->name, + 'team_id' => $server->team_id, + 'server_id' => $server->id, + ]); + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Error processing task', [ + 'task_id' => $task->id, + 'error' => $e->getMessage(), + ]); + } } private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string @@ -337,71 +406,70 @@ private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string private function processDockerCleanups(): void { - // Get all servers that need cleanup checks - $servers = $this->getServersForCleanup(); - - foreach ($servers as $server) { - try { - $skipReason = $this->getDockerCleanupSkipReason($server); - if ($skipReason !== null) { - $this->skippedCount++; - $this->logSkip('docker_cleanup', $skipReason, [ - 'server_id' => $server->id, - 'server_name' => $server->name, - 'team_id' => $server->team_id, - ]); - - continue; + $this->getServersForCleanupQuery() + ->chunkById(self::CHUNK_SIZE, function ($servers): void { + foreach ($servers as $server) { + $this->processDockerCleanup($server); } + }); + } - $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); - if (validate_timezone($serverTimezone) === false) { - $serverTimezone = config('app.timezone'); - } - - $frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *'); - if (isset(VALID_CRON_STRINGS[$frequency])) { - $frequency = VALID_CRON_STRINGS[$frequency]; - } - - // Use the frozen execution time for consistent evaluation - if (shouldRunCronNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}", $this->executionTime)) { - DockerCleanupJob::dispatch( - $server, - false, - $server->settings->delete_unused_volumes, - $server->settings->delete_unused_networks - ); - $this->dispatchedCount++; - Log::channel('scheduled')->info('Docker cleanup dispatched', [ - 'server_id' => $server->id, - 'server_name' => $server->name, - 'team_id' => $server->team_id, - ]); - } - } catch (\Exception $e) { - Log::channel('scheduled-errors')->error('Error processing docker cleanup', [ + private function processDockerCleanup(Server $server): void + { + try { + $skipReason = $this->getDockerCleanupSkipReason($server); + if ($skipReason !== null) { + $this->skippedCount++; + $this->logSkip('docker_cleanup', $skipReason, [ 'server_id' => $server->id, 'server_name' => $server->name, - 'error' => $e->getMessage(), + 'team_id' => $server->team_id, + ]); + + return; + } + + $frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *'); + + if ($this->shouldDispatch($frequency, $server, "docker-cleanup:{$server->id}")) { + DockerCleanupJob::dispatch( + $server, + false, + $server->settings->delete_unused_volumes, + $server->settings->delete_unused_networks + ); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Docker cleanup dispatched', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'team_id' => $server->team_id, ]); } + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Error processing docker cleanup', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'error' => $e->getMessage(), + ]); } } - private function getServersForCleanup(): Collection + private function getServersForCleanupQuery(): Builder { $query = Server::with('settings') ->where('ip', '!=', '1.2.3.4'); if (isCloud()) { - $servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get(); - $own = Team::find(0)->servers()->with('settings')->get(); - - return $servers->merge($own); + $query + ->with('team.subscription') + ->where(function (Builder $query): void { + $query + ->where('team_id', 0) + ->orWhereRelation('team.subscription', 'stripe_invoice_paid', true); + }); } - return $query->get(); + return $query; } private function getDockerCleanupSkipReason(Server $server): ?string @@ -428,4 +496,71 @@ private function logSkip(string $type, string $reason, array $context = []): voi 'execution_time' => $this->executionTime?->toIso8601String(), ], $context)); } + + private function shouldDispatch(string $frequency, Server $server, string $dedupKey): bool + { + return shouldRunCronNow( + $this->normalizeFrequency($frequency), + $this->serverTimezone($server), + $dedupKey, + $this->executionTime, + ); + } + + private function isDueCandidateBeforeExpensiveChecks(string $frequency, Server $server, string $dedupKey): bool + { + $cron = new CronExpression($this->normalizeFrequency($frequency)); + $executionTime = ($this->executionTime ?? Carbon::now())->copy()->setTimezone($this->serverTimezone($server)); + $lastDispatched = Cache::get($dedupKey); + $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true)); + + if ($lastDispatched === null) { + $isDue = $cron->isDue($executionTime); + + if (! $isDue) { + Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000); + } + + return $isDue; + } + + $shouldFire = $previousDue->gt(Carbon::parse($lastDispatched)); + + if (! $shouldFire) { + Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000); + } + + return $shouldFire; + } + + private function normalizeFrequency(string $frequency): string + { + return VALID_CRON_STRINGS[$frequency] ?? $frequency; + } + + private function serverTimezone(Server $server): string + { + $timezone = data_get($server->settings, 'server_timezone', config('app.timezone')); + + return validate_timezone($timezone) ? $timezone : config('app.timezone'); + } + + private function logBackupSkip(ScheduledDatabaseBackup $backup, string $reason): void + { + $this->logSkip('backup', $reason, [ + 'backup_id' => $backup->id, + 'database_id' => $backup->database_id, + 'database_type' => $backup->database_type, + 'team_id' => $backup->team_id ?? null, + ]); + } + + private function logTaskSkip(ScheduledTask $task, string $reason, ?Server $server): void + { + $this->logSkip('task', $reason, [ + 'task_id' => $task->id, + 'task_name' => $task->name, + 'team_id' => $server?->team_id, + ]); + } } diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index 49b9b9702..dc11ec89e 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -40,13 +40,13 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue */ public $timeout = 300; - public Team $team; + public ?Team $team = null; public ?Server $server = null; public ScheduledTask $task; - public Application|Service $resource; + public Application|Service|null $resource = null; public ?ScheduledTaskExecution $task_log = null; @@ -61,25 +61,34 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue public array $containers = []; - public string $server_timezone; + public string $server_timezone = 'UTC'; - public function __construct($task) + public function __construct(ScheduledTask $task) { - $this->onQueue('high'); + $this->onQueue(crons_queue()); $this->task = $task; - if ($service = $task->service()->first()) { - $this->resource = $service; - } elseif ($application = $task->application()->first()) { - $this->resource = $application; + $this->timeout = $this->task->timeout ?? 300; + } + + private function initializeExecutionContext(): void + { + $this->task->loadMissing([ + 'service.destination.server.settings', + 'application.destination.server.settings', + ]); + + if ($this->task->service) { + $this->resource = $this->task->service; + } elseif ($this->task->application) { + $this->resource = $this->task->application; } else { throw new \RuntimeException('ScheduledTaskJob failed: No resource found.'); } - $this->team = Team::findOrFail($task->team_id); - $this->server_timezone = $this->getServerTimezone(); - // Set timeout from task configuration - $this->timeout = $this->task->timeout ?? 300; + $this->team = Team::findOrFail($this->task->team_id); + $this->server_timezone = $this->getServerTimezone(); + $this->server = $this->resource->destination->server; } private function getServerTimezone(): string @@ -98,6 +107,8 @@ public function handle(): void $startTime = Carbon::now(); try { + $this->initializeExecutionContext(); + $this->task_log = ScheduledTaskExecution::create([ 'scheduled_task_id' => $this->task->id, 'started_at' => $startTime, @@ -107,8 +118,6 @@ public function handle(): void // Store execution ID for timeout handling $this->executionId = $this->task_log->id; - $this->server = $this->resource->destination->server; - if ($this->resource->type() === 'application') { $containers = getCurrentApplicationContainerStatus($this->server, $this->resource->id, 0); if ($containers->count() > 0) { @@ -179,7 +188,10 @@ public function handle(): void // Re-throw to trigger Laravel's retry mechanism with backoff throw $e; } finally { - ScheduledTaskDone::dispatch($this->team->id); + if ($this->team) { + ScheduledTaskDone::dispatch($this->team->id); + } + if ($this->task_log) { $finishedAt = Carbon::now(); $duration = round($startTime->floatDiffInSeconds($finishedAt), 2); @@ -205,6 +217,8 @@ public function backoff(): array */ public function failed(?\Throwable $exception): void { + $this->team ??= Team::find($this->task->team_id); + Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [ 'job' => 'ScheduledTaskJob', 'task_id' => $this->task->uuid, diff --git a/app/Livewire/Destination/Index.php b/app/Livewire/Destination/Index.php index a3df3fd56..7a4b89fab 100644 --- a/app/Livewire/Destination/Index.php +++ b/app/Livewire/Destination/Index.php @@ -3,6 +3,7 @@ namespace App\Livewire\Destination; use App\Models\Server; +use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Component; @@ -11,9 +12,15 @@ class Index extends Component #[Locked] public $servers; - public function mount() + #[Locked] + public Collection $destinations; + + public function mount(): void { $this->servers = Server::isUsable()->get(); + $this->destinations = $this->servers + ->flatMap(fn (Server $server) => $server->standaloneDockers->concat($server->swarmDockers)) + ->values(); } public function render() diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php index 6f9b6f995..254823163 100644 --- a/app/Livewire/Destination/New/Docker.php +++ b/app/Livewire/Destination/New/Docker.php @@ -33,44 +33,49 @@ class Docker extends Component #[Validate(['required', 'boolean'])] public bool $isSwarm = false; - public function mount(?string $server_id = null) + public function mount(?string $server_id = null): void { - $this->network = new Cuid2; + $this->network = (string) new Cuid2; $this->servers = Server::isUsable()->get(); - if ($server_id) { - $foundServer = $this->servers->find($server_id) ?: $this->servers->first(); - if (! $foundServer) { - throw new \Exception('Server not found.'); + + if (filled($server_id)) { + $this->selectedServer = Server::ownedByCurrentTeam()->whereKey($server_id)->firstOrFail(); + + if (! $this->servers->contains('id', $this->selectedServer->id)) { + $this->servers->push($this->selectedServer); } - $this->selectedServer = $foundServer; - $this->serverId = $this->selectedServer->id; + + $this->serverId = (string) $this->selectedServer->id; } else { $foundServer = $this->servers->first(); if (! $foundServer) { throw new \Exception('Server not found.'); } $this->selectedServer = $foundServer; - $this->serverId = $this->selectedServer->id; + $this->serverId = (string) $this->selectedServer->id; } $this->generateName(); } - public function updatedServerId() + public function updatedServerId(): void { $this->selectedServer = $this->servers->find($this->serverId); + if (! $this->selectedServer) { + throw new \Exception('Server not found.'); + } $this->generateName(); } - public function generateName() + public function generateName(): void { $name = data_get($this->selectedServer, 'name', new Cuid2); $this->name = str("{$name}-{$this->network}")->kebab(); } - public function submit() + public function submit(): mixed { try { - $this->authorize('create', StandaloneDocker::class); + $this->authorize('create', $this->isSwarm ? SwarmDocker::class : StandaloneDocker::class); $this->validate(); if ($this->isSwarm) { $found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first(); diff --git a/app/Livewire/Profile/Appearance.php b/app/Livewire/Profile/Appearance.php new file mode 100644 index 000000000..6a1b72f80 --- /dev/null +++ b/app/Livewire/Profile/Appearance.php @@ -0,0 +1,13 @@ +user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => '$refresh', - "echo-private:team.{$teamId},ServiceStatusChanged" => '$refresh', - 'buildPackUpdated' => '$refresh', - 'refresh' => '$refresh', - ]; - } + protected $listeners = [ + 'buildPackUpdated' => '$refresh', + 'refresh' => '$refresh', + ]; public function mount() { @@ -51,8 +44,6 @@ public function mount() $this->environment = $environment; $this->application = $application; - - if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') { return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]); } diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 258b54eed..0295aa5fc 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -5,6 +5,7 @@ use App\Actions\Application\GenerateConfig; use App\Jobs\ApplicationDeploymentJob; use App\Models\Application; +use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -144,7 +145,7 @@ protected function rules(): array 'description' => ValidationPatterns::descriptionRules(), 'fqdn' => 'nullable', 'gitRepository' => 'required', - 'gitBranch' => 'required', + 'gitBranch' => ['required', 'string', new ValidGitBranch], 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'installCommand' => ValidationPatterns::shellSafeCommandRules(), 'buildCommand' => ValidationPatterns::shellSafeCommandRules(), @@ -157,8 +158,8 @@ protected function rules(): array 'portsMappings' => ValidationPatterns::portMappingRules(), 'customNetworkAliases' => 'nullable', 'dockerfile' => 'nullable', - 'dockerRegistryImageName' => 'nullable', - 'dockerRegistryImageTag' => 'nullable', + 'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(), + 'dockerRegistryImageTag' => ValidationPatterns::dockerImageTagRules(), 'dockerfileLocation' => ValidationPatterns::filePathRules(), 'dockerComposeLocation' => ValidationPatterns::filePathRules(), 'dockerCompose' => 'nullable', @@ -848,7 +849,7 @@ public function submit($showToaster = true) } if ($this->buildPack === 'dockerimage') { $this->validate([ - 'dockerRegistryImageName' => 'required', + 'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(required: true), ]); } diff --git a/app/Livewire/Project/Application/ServerStatusBadge.php b/app/Livewire/Project/Application/ServerStatusBadge.php new file mode 100644 index 000000000..459271e28 --- /dev/null +++ b/app/Livewire/Project/Application/ServerStatusBadge.php @@ -0,0 +1,41 @@ +currentTeam(); + if (! $team) { + return []; + } + + return [ + "echo-private:team.{$team->id},ServiceStatusChanged" => 'refreshStatus', + "echo-private:team.{$team->id},ServiceChecked" => 'refreshStatus', + ]; + } + + public function refreshStatus(): void + { + $this->application->refresh(); + } + + public function render(): View + { + return view('livewire.project.application.server-status-badge'); + } +} diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 3ef5ccf7c..3ee5919fe 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -3,7 +3,10 @@ namespace App\Livewire\Project\Application; use App\Models\Application; +use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\PrivateKey; +use App\Rules\ValidGitBranch; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -21,13 +24,13 @@ class Source extends Component #[Validate(['nullable', 'string'])] public ?string $privateKeyName = null; - #[Validate(['nullable', 'integer'])] + #[Locked] public ?int $privateKeyId = null; #[Validate(['required', 'string'])] public string $gitRepository; - #[Validate(['required', 'string'])] + #[Validate(['required', 'string', new ValidGitBranch])] public string $gitBranch; #[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])] @@ -103,7 +106,8 @@ public function setPrivateKey(int $privateKeyId) { try { $this->authorize('update', $this->application); - $this->privateKeyId = $privateKeyId; + $key = PrivateKey::ownedByCurrentTeam()->findOrFail($privateKeyId); + $this->privateKeyId = $key->id; $this->syncData(true); $this->getPrivateKeys(); $this->application->refresh(); @@ -136,8 +140,11 @@ public function changeSource($sourceId, $sourceType) try { $this->authorize('update', $this->application); + $allowedSourceTypes = [GithubApp::class, GitlabApp::class]; + abort_unless(in_array($sourceType, $allowedSourceTypes, true), 404); + $source = $sourceType::ownedByCurrentTeam()->findOrFail($sourceId); $this->application->update([ - 'source_id' => $sourceId, + 'source_id' => $source->id, 'source_type' => $sourceType, ]); diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index e6edf726e..6a1175dc7 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; +use App\Models\ServiceDatabase; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; @@ -144,7 +145,7 @@ public function delete($password, $selectedActions = []) try { $server = null; - if ($this->backup->database instanceof \App\Models\ServiceDatabase) { + if ($this->backup->database instanceof ServiceDatabase) { $server = $this->backup->database->service->destination->server; } elseif ($this->backup->database->destination && $this->backup->database->destination->server) { $server = $this->backup->database->destination->server; @@ -170,7 +171,7 @@ public function delete($password, $selectedActions = []) $this->backup->delete(); - if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($this->backup->database->getMorphClass() === ServiceDatabase::class) { $serviceDatabase = $this->backup->database; return redirect()->route('project.service.database.backups', [ @@ -182,7 +183,7 @@ public function delete($password, $selectedActions = []) } else { return redirect()->route('project.database.backup.index', $this->parameters); } - } catch (\Exception $e) { + } catch (Exception $e) { $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); return handleError($e, $this); @@ -219,6 +220,13 @@ private function customValidate() $this->backup->s3_storage_id = null; } + // S3 backup cannot be enabled without a valid S3 storage owned by the team + $availableS3Ids = collect($this->s3s)->pluck('id'); + if ($this->backup->save_s3 && ! $availableS3Ids->contains($this->backup->s3_storage_id)) { + $this->backup->save_s3 = $this->saveS3 = false; + $this->backup->s3_storage_id = $this->s3StorageId = null; + } + // Validate that disable_local_backup can only be true when S3 backup is enabled if ($this->backup->disable_local_backup && ! $this->backup->save_s3) { $this->backup->disable_local_backup = $this->disableLocalBackup = false; @@ -226,7 +234,7 @@ private function customValidate() $isValid = validate_cron_expression($this->backup->frequency); if (! $isValid) { - throw new \Exception('Invalid Cron / Human expression'); + throw new Exception('Invalid Cron / Human expression'); } $this->validate(); } diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index a200d55e3..ad5e45b3f 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -40,20 +40,23 @@ class General extends Component public ?string $customDockerRunOptions = null; - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - public bool $isLogDrainEnabled = false; public bool $isPasswordHiddenForMember = false; - public function getListeners() + public function getListeners(): array { - $teamId = Auth::user()->currentTeam()->id; + $user = Auth::user(); + if (! $user) { + return []; + } + $team = $user->currentTeam(); + if (! $team) { + return []; + } return [ - "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', + "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped', ]; } @@ -75,8 +78,6 @@ public function mount() $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; if ($this->isPasswordHiddenForMember) { $this->clickhouseAdminPassword = ''; - $this->dbUrl = null; - $this->dbUrlPublic = null; } } @@ -97,8 +98,6 @@ protected function rules(): array 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', 'isLogDrainEnabled' => 'nullable|boolean', ]; } @@ -138,9 +137,6 @@ public function syncData(bool $toModel = false) $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -153,8 +149,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } } @@ -203,6 +197,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -211,9 +206,13 @@ public function instantSave() } } - public function databaseProxyStopped() + public function databaseProxyStopped(): void { - $this->syncData(); + $this->database->refresh(); + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; + $this->dispatch('databaseUpdated'); } public function submit() @@ -229,6 +228,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { diff --git a/app/Livewire/Project/Database/Clickhouse/StatusInfo.php b/app/Livewire/Project/Database/Clickhouse/StatusInfo.php new file mode 100644 index 000000000..51a3192fa --- /dev/null +++ b/app/Livewire/Project/Database/Clickhouse/StatusInfo.php @@ -0,0 +1,31 @@ +currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => '$refresh', - ]; - } - public function mount() { try { @@ -55,10 +47,10 @@ public function mount() $this->dispatch('configurationChanged'); } } catch (\Throwable $e) { - if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) { + if ($e instanceof AuthorizationException) { return redirect()->route('dashboard'); } - if ($e instanceof \Illuminate\Support\ItemNotFoundException) { + if ($e instanceof ItemNotFoundException) { return redirect()->route('dashboard'); } diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index 7f807afe2..7384adcff 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -2,7 +2,9 @@ namespace App\Livewire\Project\Database; +use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; +use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; @@ -48,6 +50,20 @@ public function submit() $this->validate(); + if ($this->saveToS3) { + $s3StorageExists = ! is_null($this->s3StorageId) + && S3Storage::where('team_id', currentTeam()->id) + ->where('is_usable', true) + ->whereKey($this->s3StorageId) + ->exists(); + + if (! $s3StorageExists) { + $this->dispatch('error', 'Please select a valid S3 storage to enable S3 backups.'); + + return; + } + } + $isValid = validate_cron_expression($this->frequency); if (! $isValid) { $this->dispatch('error', 'Invalid Cron / Human expression.'); @@ -74,7 +90,7 @@ public function submit() } $databaseBackup = ScheduledDatabaseBackup::create($payload); - if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($this->database->getMorphClass() === ServiceDatabase::class) { $this->dispatch('refreshScheduledBackups', $databaseBackup->id); } else { $this->dispatch('refreshScheduledBackups'); diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 1f29d4290..2f5b84484 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -4,11 +4,9 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneDragonfly; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; @@ -40,27 +38,23 @@ class General extends Component public ?string $customDockerRunOptions = null; - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - public bool $isLogDrainEnabled = false; - public ?Carbon $certificateValidUntil = null; - - public bool $enable_ssl = false; - public bool $isPasswordHiddenForMember = false; - public function getListeners() + public function getListeners(): array { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; + $user = Auth::user(); + if (! $user) { + return []; + } + $team = $user->currentTeam(); + if (! $team) { + return []; + } return [ - "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped', ]; } @@ -75,12 +69,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -88,8 +76,6 @@ public function mount() $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; if ($this->isPasswordHiddenForMember) { $this->dragonflyPassword = ''; - $this->dbUrl = null; - $this->dbUrlPublic = null; } } @@ -107,10 +93,7 @@ protected function rules(): array 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', 'isLogDrainEnabled' => 'nullable|boolean', - 'enable_ssl' => 'nullable|boolean', ]; } @@ -146,11 +129,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->database->enable_ssl = $this->enable_ssl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -162,9 +141,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->enable_ssl = $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } } @@ -213,6 +189,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -221,9 +198,13 @@ public function instantSave() } } - public function databaseProxyStopped() + public function databaseProxyStopped(): void { - $this->syncData(); + $this->database->refresh(); + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; + $this->dispatch('databaseUpdated'); } public function submit() @@ -239,6 +220,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -250,67 +232,6 @@ public function submit() } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $server = $this->database->destination->server; - - $caCert = $server->sslCertificates() - ->where('is_ca_certificate', true) - ->first(); - - if (! $caCert) { - $server->generateCaCertificate(); - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Dragonfly/StatusInfo.php b/app/Livewire/Project/Database/Dragonfly/StatusInfo.php new file mode 100644 index 000000000..baeb3d09f --- /dev/null +++ b/app/Livewire/Project/Database/Dragonfly/StatusInfo.php @@ -0,0 +1,26 @@ +authorize('view', $this->database); + $this->syncData(); + } + + public function syncData(bool $toModel = false): void + { + if ($toModel) { + $this->validate(); + $this->database->health_check_enabled = $this->healthCheckEnabled; + $this->database->health_check_interval = $this->healthCheckInterval; + $this->database->health_check_timeout = $this->healthCheckTimeout; + $this->database->health_check_retries = $this->healthCheckRetries; + $this->database->health_check_start_period = $this->healthCheckStartPeriod; + $this->database->save(); + } else { + $this->healthCheckEnabled = $this->database->health_check_enabled; + $this->healthCheckInterval = $this->database->health_check_interval; + $this->healthCheckTimeout = $this->database->health_check_timeout; + $this->healthCheckRetries = $this->database->health_check_retries; + $this->healthCheckStartPeriod = $this->database->health_check_start_period; + } + } + + public function instantSave(): void + { + $this->submit(); + } + + public function submit(): void + { + $updateSuccessful = false; + + try { + $this->authorize('update', $this->database); + $this->syncData(true); + $updateSuccessful = true; + $this->dispatch('success', 'Health check updated. Restart the database to apply the changes.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + + if (! $updateSuccessful) { + return; + } + + $this->markConfigurationChanged(); + } + + public function toggleHealthcheck(): void + { + $updateSuccessful = false; + + try { + $this->authorize('update', $this->database); + $this->healthCheckEnabled = ! $this->healthCheckEnabled; + $this->syncData(true); + $updateSuccessful = true; + $this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'. Restart the database to apply the changes.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + + if (! $updateSuccessful) { + return; + } + + $this->markConfigurationChanged(); + } + + private function markConfigurationChanged(): void + { + if (is_null($this->database->config_hash)) { + $this->database->isConfigurationChanged(true); + + return; + } + + $this->dispatch('configurationChanged'); + } + + public function render(): View + { + return view('livewire.project.database.health'); + } +} diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 1cdc681cd..ea04658cf 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -2,14 +2,14 @@ namespace App\Livewire\Project\Database; -use App\Models\S3Storage; -use App\Models\Server; -use App\Models\Service; -use App\Support\ValidationPatterns; +use App\Models\ServiceDatabase; +use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneRedis; +use Illuminate\Contracts\View\View; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Storage; -use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Component; @@ -17,797 +17,134 @@ class Import extends Component { use AuthorizesRequests; - /** - * Validate that a string is safe for use as an S3 bucket name. - * Allows alphanumerics, dots, dashes, and underscores. - */ - private function validateBucketName(string $bucket): bool - { - return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1; - } - - /** - * Validate that a string is safe for use as an S3 path. - * Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters. - */ - private function validateS3Path(string $path): bool - { - // Must not be empty - if (empty($path)) { - return false; - } - - // Must not contain dangerous shell metacharacters or command injection patterns - $dangerousPatterns = [ - '..', // Directory traversal - '$(', // Command substitution - '`', // Backtick command substitution - '|', // Pipe - ';', // Command separator - '&', // Background/AND - '>', // Redirect - '<', // Redirect - "\n", // Newline - "\r", // Carriage return - "\0", // Null byte - "'", // Single quote - '"', // Double quote - '\\', // Backslash - ]; - - foreach ($dangerousPatterns as $pattern) { - if (str_contains($path, $pattern)) { - return false; - } - } - - // Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at - return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1; - } - - /** - * Validate that a string is safe for use as a file path on the server. - */ - private function validateServerPath(string $path): bool - { - // Must be an absolute path - if (! str_starts_with($path, '/')) { - return false; - } - - // Must not contain dangerous shell metacharacters or command injection patterns - $dangerousPatterns = [ - '..', // Directory traversal - '$(', // Command substitution - '`', // Backtick command substitution - '|', // Pipe - ';', // Command separator - '&', // Background/AND - '>', // Redirect - '<', // Redirect - "\n", // Newline - "\r", // Carriage return - "\0", // Null byte - "'", // Single quote - '"', // Double quote - '\\', // Backslash - ]; - - foreach ($dangerousPatterns as $pattern) { - if (str_contains($path, $pattern)) { - return false; - } - } - - // Allow alphanumerics, dots, dashes, underscores, slashes, and spaces - return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1; - } - - public bool $unsupported = false; - - // Store IDs instead of models for proper Livewire serialization #[Locked] public ?int $resourceId = null; #[Locked] public ?string $resourceType = null; - #[Locked] - public ?int $serverId = null; - - // View-friendly properties to avoid computed property access in Blade - #[Locked] - public string $resourceUuid = ''; - public string $resourceStatus = ''; - #[Locked] - public string $resourceDbType = ''; + public string $resourceUuid = ''; - public array $parameters = []; + public bool $unsupported = false; - public array $containers = []; - - public bool $scpInProgress = false; - - public bool $importRunning = false; - - public ?string $filename = null; - - public ?string $filesize = null; - - public bool $isUploading = false; - - public int $progress = 0; - - public bool $error = false; - - #[Locked] - public string $container; - - public array $importCommands = []; - - public bool $dumpAll = false; - - public string $restoreCommandText = ''; - - public string $customLocation = ''; - - public ?int $activityId = null; - - public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; - - public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; - - public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; - - public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='; - - // S3 Restore properties - public array $availableS3Storages = []; - - public ?int $s3StorageId = null; - - public string $s3Path = ''; - - public ?int $s3FileSize = null; - - #[Computed] - public function resource() + public function getListeners(): array { - if ($this->resourceId === null || $this->resourceType === null) { - return null; + $listeners = ['databaseUpdated' => 'refreshStatus']; + + $user = Auth::user(); + if (! $user) { + return $listeners; } - return $this->resourceType::find($this->resourceId); - } + $listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refreshStatus'; - #[Computed] - public function server() - { - if ($this->serverId === null) { - return null; + $team = $user->currentTeam(); + if ($team) { + $listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refreshStatus'; } - return Server::ownedByCurrentTeam()->find($this->serverId); + return $listeners; } - public function getListeners() + public function mount(): void { - $userId = Auth::id(); - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', - 'slideOverClosed' => 'resetActivityId', - ]; - } - - public function resetActivityId() - { - $this->activityId = null; - } - - public function mount() - { - $this->parameters = get_route_parameters(); - $this->getContainers(); - $this->loadAvailableS3Storages(); - } - - public function updatedDumpAll($value) - { - $morphClass = $this->resource->getMorphClass(); - - // Handle ServiceDatabase by checking the database type - if ($morphClass === \App\Models\ServiceDatabase::class) { - $dbType = $this->resource->databaseType(); - if (str_contains($dbType, 'mysql')) { - $morphClass = 'mysql'; - } elseif (str_contains($dbType, 'mariadb')) { - $morphClass = 'mariadb'; - } elseif (str_contains($dbType, 'postgres')) { - $morphClass = 'postgresql'; - } - } - - switch ($morphClass) { - case \App\Models\StandaloneMariadb::class: - case 'mariadb': - if ($value === true) { - $this->mariadbRestoreCommand = <<<'EOD' -for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do - mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true -done && \ -mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \ -mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \ -(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default} -EOD; - $this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}'; - } else { - $this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; - } - break; - case \App\Models\StandaloneMysql::class: - case 'mysql': - if ($value === true) { - $this->mysqlRestoreCommand = <<<'EOD' -for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do - mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true -done && \ -mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \ -mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \ -(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default} -EOD; - $this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}'; - } else { - $this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; - } - break; - case \App\Models\StandalonePostgresql::class: - case 'postgresql': - if ($value === true) { - $this->postgresqlRestoreCommand = <<<'EOD' -psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \ -psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \ -createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} -EOD; - $this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; - } else { - $this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; - } - break; - } - - } - - public function getContainers() - { - $this->containers = []; - $teamId = data_get(auth()->user()->currentTeam(), 'id'); - - // Try to find resource by route parameter - $databaseUuid = data_get($this->parameters, 'database_uuid'); - $stackServiceUuid = data_get($this->parameters, 'stack_service_uuid'); - - $resource = null; - if ($databaseUuid) { - // Standalone database route - $resource = getResourceByUuid($databaseUuid, $teamId); - if (is_null($resource)) { - abort(404); - } - } elseif ($stackServiceUuid) { - // ServiceDatabase route - look up the service database - $serviceUuid = data_get($this->parameters, 'service_uuid'); - $service = Service::whereUuid($serviceUuid)->first(); - if (! $service) { - abort(404); - } - $resource = $service->databases()->whereUuid($stackServiceUuid)->first(); - if (is_null($resource)) { - abort(404); - } - } else { - abort(404); - } - + $resource = $this->resolveResourceFromRoute(); $this->authorize('view', $resource); - // Store IDs for Livewire serialization $this->resourceId = $resource->id; $this->resourceType = get_class($resource); - // Store view-friendly properties + $this->refreshStatus(); + } + + public function refreshStatus(): void + { + $resource = $this->resolveStoredResource(); + $this->authorize('view', $resource); + + $resource->refresh(); + $this->resourceUuid = $resource->uuid; $this->resourceStatus = $resource->status ?? ''; + $this->unsupported = $this->isUnsupportedResource($resource); + } - // Handle ServiceDatabase server access differently - if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { - $server = $resource->service?->server; - if (! $server) { - abort(404, 'Server not found for this service database.'); - } - $this->serverId = $server->id; - $this->container = $resource->name.'-'.$resource->service->uuid; - $this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID + public function render(): View + { + return view('livewire.project.database.import'); + } - // Determine database type for ServiceDatabase - $dbType = $resource->databaseType(); - if (str_contains($dbType, 'postgres')) { - $this->resourceDbType = 'standalone-postgresql'; - } elseif (str_contains($dbType, 'mysql')) { - $this->resourceDbType = 'standalone-mysql'; - } elseif (str_contains($dbType, 'mariadb')) { - $this->resourceDbType = 'standalone-mariadb'; - } elseif (str_contains($dbType, 'mongo')) { - $this->resourceDbType = 'standalone-mongodb'; - } else { - $this->resourceDbType = $dbType; + private function resolveResourceFromRoute(): object + { + $parameters = get_route_parameters(); + $teamId = data_get(Auth::user()?->currentTeam(), 'id'); + $databaseUuid = data_get($parameters, 'database_uuid'); + $stackServiceUuid = data_get($parameters, 'stack_service_uuid'); + + if ($databaseUuid) { + $resource = getResourceByUuid($databaseUuid, $teamId); + if ($resource) { + return $resource; } - } else { - $server = $resource->destination?->server; - if (! $server) { - abort(404, 'Server not found for this database.'); - } - $this->serverId = $server->id; - $this->container = $resource->uuid; - $this->resourceUuid = $resource->uuid; - $this->resourceDbType = $resource->type(); + + abort(404); } - if (str($resource->status)->startsWith('running')) { - $this->containers[] = $this->container; + if ($stackServiceUuid) { + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', data_get($parameters, 'project_uuid')) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') + ->where('uuid', data_get($parameters, 'environment_uuid')) + ->firstOrFail(); + $service = $environment->services()->whereUuid(data_get($parameters, 'service_uuid'))->firstOrFail(); + $resource = $service->databases()->whereUuid($stackServiceUuid)->first(); + if ($resource) { + return $resource; + } } + abort(404); + } + + private function resolveStoredResource(): object + { + if ($this->resourceId === null || $this->resourceType === null) { + return $this->resolveResourceFromRoute(); + } + + $resource = $this->resourceType::find($this->resourceId); + if ($resource) { + return $resource; + } + + abort(404); + } + + private function isUnsupportedResource(object $resource): bool + { if ( - $resource->getMorphClass() === \App\Models\StandaloneRedis::class || - $resource->getMorphClass() === \App\Models\StandaloneKeydb::class || - $resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || - $resource->getMorphClass() === \App\Models\StandaloneClickhouse::class + $resource instanceof StandaloneRedis || + $resource instanceof StandaloneKeydb || + $resource instanceof StandaloneDragonfly || + $resource instanceof StandaloneClickhouse ) { - $this->unsupported = true; + return true; } - // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) - if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($resource instanceof ServiceDatabase) { $dbType = $resource->databaseType(); - if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') || - str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) { - $this->unsupported = true; - } - } - } - public function checkFile() - { - if (filled($this->customLocation)) { - // Validate the custom location to prevent command injection - if (! $this->validateServerPath($this->customLocation)) { - $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); - - return; - } - - if (! $this->server) { - $this->dispatch('error', 'Server not found. Please refresh the page.'); - - return; - } - - try { - $escapedPath = escapeshellarg($this->customLocation); - $result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false); - if (blank($result)) { - $this->dispatch('error', 'The file does not exist or has been deleted.'); - - return; - } - $this->filename = $this->customLocation; - $this->dispatch('success', 'The file exists.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - } - - public function runImport(string $password = ''): bool|string - { - if (! verifyPasswordConfirmation($password, $this)) { - return 'The provided password is incorrect.'; + return str_contains($dbType, 'redis') || + str_contains($dbType, 'keydb') || + str_contains($dbType, 'dragonfly') || + str_contains($dbType, 'clickhouse'); } - $this->authorize('update', $this->resource); - - if (! ValidationPatterns::isValidContainerName($this->container)) { - $this->dispatch('error', 'Invalid container name.'); - - return true; - } - - if ($this->filename === '') { - $this->dispatch('error', 'Please select a file to import.'); - - return true; - } - - if (! $this->server) { - $this->dispatch('error', 'Server not found. Please refresh the page.'); - - return true; - } - - try { - $this->importRunning = true; - $this->importCommands = []; - $backupFileName = "upload/{$this->resourceUuid}/restore"; - - // Check if an uploaded file exists first (takes priority over custom location) - if (Storage::exists($backupFileName)) { - $path = Storage::path($backupFileName); - $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid; - instant_scp($path, $tmpPath, $this->server); - Storage::delete($backupFileName); - $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}"; - } elseif (filled($this->customLocation)) { - // Validate the custom location to prevent command injection - if (! $this->validateServerPath($this->customLocation)) { - $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.'); - - return true; - } - $tmpPath = '/tmp/restore_'.$this->resourceUuid; - $escapedCustomLocation = escapeshellarg($this->customLocation); - $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}"; - } else { - $this->dispatch('error', 'The file does not exist or has been deleted.'); - - return true; - } - - // Copy the restore command to a script file - $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; - - $restoreCommand = $this->buildRestoreCommand($tmpPath); - - $restoreCommandBase64 = base64_encode($restoreCommand); - $this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}"; - $this->importCommands[] = "chmod +x {$scriptPath}"; - $this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}"; - - $this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'"; - $this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; - - if (! empty($this->importCommands)) { - $activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [ - 'scriptPath' => $scriptPath, - 'tmpPath' => $tmpPath, - 'container' => $this->container, - 'serverId' => $this->server->id, - ]); - - // Track the activity ID - $this->activityId = $activity->id; - - // Dispatch activity to the monitor and open slide-over - $this->dispatch('activityMonitor', $activity->id); - $this->dispatch('databaserestore'); - } - } catch (\Throwable $e) { - handleError($e, $this); - - return true; - } finally { - $this->filename = null; - $this->importCommands = []; - } - - return true; - } - - public function loadAvailableS3Storages() - { - try { - $this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description']) - ->where('is_usable', true) - ->get() - ->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description]) - ->toArray(); - } catch (\Throwable $e) { - $this->availableS3Storages = []; - } - } - - public function updatedS3Path($value) - { - // Reset validation state when path changes - $this->s3FileSize = null; - - // Ensure path starts with a slash - if ($value !== null && $value !== '') { - $this->s3Path = str($value)->trim()->start('/')->value(); - } - } - - public function updatedS3StorageId() - { - // Reset validation state when storage changes - $this->s3FileSize = null; - } - - public function checkS3File() - { - if (! $this->s3StorageId) { - $this->dispatch('error', 'Please select an S3 storage.'); - - return; - } - - if (blank($this->s3Path)) { - $this->dispatch('error', 'Please provide an S3 path.'); - - return; - } - - // Clean the path (remove leading slash if present) - $cleanPath = ltrim($this->s3Path, '/'); - - // Validate the S3 path early to prevent command injection in subsequent operations - if (! $this->validateS3Path($cleanPath)) { - $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); - - return; - } - - try { - $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); - - // Validate bucket name early - if (! $this->validateBucketName($s3Storage->bucket)) { - $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); - - return; - } - - // Test connection - $s3Storage->testConnection(); - - // Build S3 disk configuration - $disk = Storage::build([ - 'driver' => 's3', - 'region' => $s3Storage->region, - 'key' => $s3Storage->key, - 'secret' => $s3Storage->secret, - 'bucket' => $s3Storage->bucket, - 'endpoint' => $s3Storage->endpoint, - 'use_path_style_endpoint' => true, - ]); - - // Check if file exists - if (! $disk->exists($cleanPath)) { - $this->dispatch('error', 'File not found in S3. Please check the path.'); - - return; - } - - // Get file size - $this->s3FileSize = $disk->size($cleanPath); - - $this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize)); - } catch (\Throwable $e) { - $this->s3FileSize = null; - - return handleError($e, $this); - } - } - - public function restoreFromS3(string $password = ''): bool|string - { - if (! verifyPasswordConfirmation($password, $this)) { - return 'The provided password is incorrect.'; - } - - $this->authorize('update', $this->resource); - - if (! ValidationPatterns::isValidContainerName($this->container)) { - $this->dispatch('error', 'Invalid container name.'); - - return true; - } - - if (! $this->s3StorageId || blank($this->s3Path)) { - $this->dispatch('error', 'Please select S3 storage and provide a path first.'); - - return true; - } - - if (is_null($this->s3FileSize)) { - $this->dispatch('error', 'Please check the file first by clicking "Check File".'); - - return true; - } - - if (! $this->server) { - $this->dispatch('error', 'Server not found. Please refresh the page.'); - - return true; - } - - try { - $this->importRunning = true; - - $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); - - $key = $s3Storage->key; - $secret = $s3Storage->secret; - $bucket = $s3Storage->bucket; - $endpoint = $s3Storage->endpoint; - - // Validate bucket name to prevent command injection - if (! $this->validateBucketName($bucket)) { - $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); - - return true; - } - - // Clean the S3 path - $cleanPath = ltrim($this->s3Path, '/'); - - // Validate the S3 path to prevent command injection - if (! $this->validateS3Path($cleanPath)) { - $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); - - return true; - } - - // Get helper image - $helperImage = config('constants.coolify.helper_image'); - $latestVersion = getHelperVersion(); - $fullImageName = "{$helperImage}:{$latestVersion}"; - - // Get the database destination network - if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) { - $destinationNetwork = $this->resource->service->destination->network ?? 'coolify'; - } else { - $destinationNetwork = $this->resource->destination->network ?? 'coolify'; - } - - // Generate unique names for this operation - $containerName = "s3-restore-{$this->resourceUuid}"; - $helperTmpPath = '/tmp/'.basename($cleanPath); - $serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath); - $containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath); - $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; - - // Prepare all commands in sequence - $commands = []; - - // 1. Clean up any existing helper container and temp files from previous runs - $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; - $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true"; - $commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true"; - - // 2. Start helper container on the database network - $commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600"; - - // 3. Configure S3 access in helper container - $escapedEndpoint = escapeshellarg($endpoint); - $escapedKey = escapeshellarg($key); - $escapedSecret = escapeshellarg($secret); - $commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; - - // 4. Check file exists in S3 (bucket and path already validated above) - $escapedBucket = escapeshellarg($bucket); - $escapedCleanPath = escapeshellarg($cleanPath); - $escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}"); - $commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}"; - - // 5. Download from S3 to helper container (progress shown by default) - $escapedHelperTmpPath = escapeshellarg($helperTmpPath); - $commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}"; - - // 6. Copy from helper to server, then immediately to database container - $commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}"; - $commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}"; - - // 7. Cleanup helper container and server temp file immediately (no longer needed) - $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; - $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true"; - - // 8. Build and execute restore command inside database container - $restoreCommand = $this->buildRestoreCommand($containerTmpPath); - - $restoreCommandBase64 = base64_encode($restoreCommand); - $commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}"; - $commands[] = "chmod +x {$scriptPath}"; - $commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}"; - - // 9. Execute restore and cleanup temp files immediately after completion - $commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'"; - $commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; - - // Execute all commands with cleanup event (as safety net for edge cases) - $activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [ - 'containerName' => $containerName, - 'serverTmpPath' => $serverTmpPath, - 'scriptPath' => $scriptPath, - 'containerTmpPath' => $containerTmpPath, - 'container' => $this->container, - 'serverId' => $this->server->id, - ]); - - // Track the activity ID - $this->activityId = $activity->id; - - // Dispatch activity to the monitor and open slide-over - $this->dispatch('activityMonitor', $activity->id); - $this->dispatch('databaserestore'); - $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); - } catch (\Throwable $e) { - $this->importRunning = false; - handleError($e, $this); - - return true; - } - - return true; - } - - public function buildRestoreCommand(string $tmpPath): string - { - $morphClass = $this->resource->getMorphClass(); - - // Handle ServiceDatabase by checking the database type - if ($morphClass === \App\Models\ServiceDatabase::class) { - $dbType = $this->resource->databaseType(); - if (str_contains($dbType, 'mysql')) { - $morphClass = 'mysql'; - } elseif (str_contains($dbType, 'mariadb')) { - $morphClass = 'mariadb'; - } elseif (str_contains($dbType, 'postgres')) { - $morphClass = 'postgresql'; - } elseif (str_contains($dbType, 'mongo')) { - $morphClass = 'mongodb'; - } - } - - switch ($morphClass) { - case \App\Models\StandaloneMariadb::class: - case 'mariadb': - $restoreCommand = $this->mariadbRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}"; - } else { - $restoreCommand .= " < {$tmpPath}"; - } - break; - case \App\Models\StandaloneMysql::class: - case 'mysql': - $restoreCommand = $this->mysqlRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}"; - } else { - $restoreCommand .= " < {$tmpPath}"; - } - break; - case \App\Models\StandalonePostgresql::class: - case 'postgresql': - $restoreCommand = $this->postgresqlRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}"; - } else { - $restoreCommand .= " {$tmpPath}"; - } - break; - case \App\Models\StandaloneMongodb::class: - case 'mongodb': - $restoreCommand = $this->mongodbRestoreCommand; - if ($this->dumpAll === false) { - $restoreCommand .= "{$tmpPath}"; - } - break; - default: - $restoreCommand = ''; - } - - return $restoreCommand; + return false; } } diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php new file mode 100644 index 000000000..ccc7b347d --- /dev/null +++ b/app/Livewire/Project/Database/ImportForm.php @@ -0,0 +1,825 @@ +', // Redirect + '<', // Redirect + "\n", // Newline + "\r", // Carriage return + "\0", // Null byte + "'", // Single quote + '"', // Double quote + '\\', // Backslash + ]; + + foreach ($dangerousPatterns as $pattern) { + if (str_contains($path, $pattern)) { + return false; + } + } + + // Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at + return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1; + } + + /** + * Validate that a string is safe for use as a file path on the server. + */ + private function validateServerPath(string $path): bool + { + // Must be an absolute path + if (! str_starts_with($path, '/')) { + return false; + } + + // Must not contain dangerous shell metacharacters or command injection patterns + $dangerousPatterns = [ + '..', // Directory traversal + '$(', // Command substitution + '`', // Backtick command substitution + '|', // Pipe + ';', // Command separator + '&', // Background/AND + '>', // Redirect + '<', // Redirect + "\n", // Newline + "\r", // Carriage return + "\0", // Null byte + "'", // Single quote + '"', // Double quote + '\\', // Backslash + ]; + + foreach ($dangerousPatterns as $pattern) { + if (str_contains($path, $pattern)) { + return false; + } + } + + // Allow alphanumerics, dots, dashes, underscores, slashes, and spaces + return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1; + } + + public bool $unsupported = false; + + // Store IDs instead of models for proper Livewire serialization + #[Locked] + public ?int $resourceId = null; + + #[Locked] + public ?string $resourceType = null; + + #[Locked] + public ?int $serverId = null; + + // View-friendly properties to avoid computed property access in Blade + #[Locked] + public string $resourceUuid = ''; + + public string $resourceStatus = ''; + + #[Locked] + public string $resourceDbType = ''; + + public array $parameters = []; + + public array $containers = []; + + public bool $scpInProgress = false; + + public bool $importRunning = false; + + public ?string $filename = null; + + public ?string $filesize = null; + + public bool $isUploading = false; + + public int $progress = 0; + + public bool $error = false; + + #[Locked] + public string $container; + + public array $importCommands = []; + + public bool $dumpAll = false; + + public string $restoreCommandText = ''; + + public string $customLocation = ''; + + public ?int $activityId = null; + + public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; + + public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; + + public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; + + public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='; + + // S3 Restore properties + public array $availableS3Storages = []; + + public ?int $s3StorageId = null; + + public string $s3Path = ''; + + public ?int $s3FileSize = null; + + #[Computed] + public function resource() + { + if ($this->resourceId === null || $this->resourceType === null) { + return null; + } + + return $this->resourceType::find($this->resourceId); + } + + #[Computed] + public function server() + { + if ($this->serverId === null) { + return null; + } + + return Server::ownedByCurrentTeam()->find($this->serverId); + } + + protected $listeners = [ + 'slideOverClosed' => 'resetActivityId', + ]; + + public function resetActivityId() + { + $this->activityId = null; + } + + public function mount() + { + $this->parameters = get_route_parameters(); + $this->getContainers(); + $this->loadAvailableS3Storages(); + } + + public function updatedDumpAll($value) + { + $morphClass = $this->resource->getMorphClass(); + + // Handle ServiceDatabase by checking the database type + if ($morphClass === ServiceDatabase::class) { + $dbType = $this->resource->databaseType(); + if (str_contains($dbType, 'mysql')) { + $morphClass = 'mysql'; + } elseif (str_contains($dbType, 'mariadb')) { + $morphClass = 'mariadb'; + } elseif (str_contains($dbType, 'postgres')) { + $morphClass = 'postgresql'; + } + } + + switch ($morphClass) { + case StandaloneMariadb::class: + case 'mariadb': + if ($value === true) { + $this->mariadbRestoreCommand = <<<'EOD' +for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do + mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true +done && \ +mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \ +mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \ +(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default} +EOD; + $this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}'; + } else { + $this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; + } + break; + case StandaloneMysql::class: + case 'mysql': + if ($value === true) { + $this->mysqlRestoreCommand = <<<'EOD' +for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do + mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true +done && \ +mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \ +mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \ +(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default} +EOD; + $this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}'; + } else { + $this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; + } + break; + case StandalonePostgresql::class: + case 'postgresql': + if ($value === true) { + $this->postgresqlRestoreCommand = <<<'EOD' +psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \ +psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \ +createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} +EOD; + $this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; + } else { + $this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; + } + break; + } + + } + + public function getContainers() + { + $this->containers = []; + $teamId = data_get(auth()->user()->currentTeam(), 'id'); + + // Try to find resource by route parameter + $databaseUuid = data_get($this->parameters, 'database_uuid'); + $stackServiceUuid = data_get($this->parameters, 'stack_service_uuid'); + + $resource = null; + if ($databaseUuid) { + // Standalone database route + $resource = getResourceByUuid($databaseUuid, $teamId); + if (is_null($resource)) { + abort(404); + } + } elseif ($stackServiceUuid) { + // ServiceDatabase route - look up the service database + $serviceUuid = data_get($this->parameters, 'service_uuid'); + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', data_get($this->parameters, 'project_uuid')) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') + ->where('uuid', data_get($this->parameters, 'environment_uuid')) + ->firstOrFail(); + $service = $environment->services()->whereUuid($serviceUuid)->firstOrFail(); + $resource = $service->databases()->whereUuid($stackServiceUuid)->first(); + if (is_null($resource)) { + abort(404); + } + } else { + abort(404); + } + + $this->authorize('view', $resource); + + // Store IDs for Livewire serialization + $this->resourceId = $resource->id; + $this->resourceType = get_class($resource); + + // Store view-friendly properties + $this->resourceStatus = $resource->status ?? ''; + + // Handle ServiceDatabase server access differently + if ($resource->getMorphClass() === ServiceDatabase::class) { + $server = $resource->service?->server; + if (! $server) { + abort(404, 'Server not found for this service database.'); + } + $this->serverId = $server->id; + $this->container = $resource->name.'-'.$resource->service->uuid; + $this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID + + // Determine database type for ServiceDatabase + $dbType = $resource->databaseType(); + if (str_contains($dbType, 'postgres')) { + $this->resourceDbType = 'standalone-postgresql'; + } elseif (str_contains($dbType, 'mysql')) { + $this->resourceDbType = 'standalone-mysql'; + } elseif (str_contains($dbType, 'mariadb')) { + $this->resourceDbType = 'standalone-mariadb'; + } elseif (str_contains($dbType, 'mongo')) { + $this->resourceDbType = 'standalone-mongodb'; + } else { + $this->resourceDbType = $dbType; + } + } else { + $server = $resource->destination?->server; + if (! $server) { + abort(404, 'Server not found for this database.'); + } + $this->serverId = $server->id; + $this->container = $resource->uuid; + $this->resourceUuid = $resource->uuid; + $this->resourceDbType = $resource->type(); + } + + if (str($resource->status)->startsWith('running')) { + $this->containers[] = $this->container; + } + + if ( + $resource->getMorphClass() === StandaloneRedis::class || + $resource->getMorphClass() === StandaloneKeydb::class || + $resource->getMorphClass() === StandaloneDragonfly::class || + $resource->getMorphClass() === StandaloneClickhouse::class + ) { + $this->unsupported = true; + } + + // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) + if ($resource->getMorphClass() === ServiceDatabase::class) { + $dbType = $resource->databaseType(); + if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') || + str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) { + $this->unsupported = true; + } + } + } + + public function checkFile() + { + if (filled($this->customLocation)) { + // Validate the custom location to prevent command injection + if (! $this->validateServerPath($this->customLocation)) { + $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); + + return; + } + + if (! $this->server) { + $this->dispatch('error', 'Server not found. Please refresh the page.'); + + return; + } + + try { + $escapedPath = escapeshellarg($this->customLocation); + $result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false); + if (blank($result)) { + $this->dispatch('error', 'The file does not exist or has been deleted.'); + + return; + } + $this->filename = $this->customLocation; + $this->dispatch('success', 'The file exists.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + } + + public function runImport(string $password = ''): bool|string + { + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + + $this->authorize('update', $this->resource); + + if (! ValidationPatterns::isValidContainerName($this->container)) { + $this->dispatch('error', 'Invalid container name.'); + + return true; + } + + if ($this->filename === '') { + $this->dispatch('error', 'Please select a file to import.'); + + return true; + } + + if (! $this->server) { + $this->dispatch('error', 'Server not found. Please refresh the page.'); + + return true; + } + + try { + $this->importRunning = true; + $this->importCommands = []; + $backupFileName = "upload/{$this->resourceUuid}/restore"; + + // Check if an uploaded file exists first (takes priority over custom location) + if (Storage::exists($backupFileName)) { + $path = Storage::path($backupFileName); + $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid; + instant_scp($path, $tmpPath, $this->server); + Storage::delete($backupFileName); + $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}"; + } elseif (filled($this->customLocation)) { + // Validate the custom location to prevent command injection + if (! $this->validateServerPath($this->customLocation)) { + $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.'); + + return true; + } + $tmpPath = '/tmp/restore_'.$this->resourceUuid; + $escapedCustomLocation = escapeshellarg($this->customLocation); + $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}"; + } else { + $this->dispatch('error', 'The file does not exist or has been deleted.'); + + return true; + } + + // Copy the restore command to a script file + $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; + + $restoreCommand = $this->buildRestoreCommand($tmpPath); + + $restoreCommandBase64 = base64_encode($restoreCommand); + $this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}"; + $this->importCommands[] = "chmod +x {$scriptPath}"; + $this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}"; + + $this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'"; + $this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; + + if (! empty($this->importCommands)) { + $activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [ + 'scriptPath' => $scriptPath, + 'tmpPath' => $tmpPath, + 'container' => $this->container, + 'serverId' => $this->server->id, + ]); + + // Track the activity ID + $this->activityId = $activity->id; + + // Dispatch activity to the monitor and open slide-over + $this->dispatch('activityMonitor', $activity->id); + $this->dispatch('databaserestore'); + } + } catch (\Throwable $e) { + handleError($e, $this); + + return true; + } finally { + $this->filename = null; + $this->importCommands = []; + } + + return true; + } + + public function loadAvailableS3Storages() + { + try { + $this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description']) + ->where('is_usable', true) + ->get() + ->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description]) + ->toArray(); + } catch (\Throwable $e) { + $this->availableS3Storages = []; + } + } + + public function updatedS3Path($value) + { + // Reset validation state when path changes + $this->s3FileSize = null; + + // Ensure path starts with a slash + if ($value !== null && $value !== '') { + $this->s3Path = str($value)->trim()->start('/')->value(); + } + } + + public function updatedS3StorageId() + { + // Reset validation state when storage changes + $this->s3FileSize = null; + } + + public function checkS3File() + { + if (! $this->s3StorageId) { + $this->dispatch('error', 'Please select an S3 storage.'); + + return; + } + + if (blank($this->s3Path)) { + $this->dispatch('error', 'Please provide an S3 path.'); + + return; + } + + // Clean the path (remove leading slash if present) + $cleanPath = ltrim($this->s3Path, '/'); + + // Validate the S3 path early to prevent command injection in subsequent operations + if (! $this->validateS3Path($cleanPath)) { + $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); + + return; + } + + try { + $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); + + // Validate bucket name early + if (! $this->validateBucketName($s3Storage->bucket)) { + $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); + + return; + } + + // Test connection + $s3Storage->testConnection(); + + // Build S3 disk configuration + $disk = Storage::build([ + 'driver' => 's3', + 'region' => $s3Storage->region, + 'key' => $s3Storage->key, + 'secret' => $s3Storage->secret, + 'bucket' => $s3Storage->bucket, + 'endpoint' => $s3Storage->endpoint, + 'use_path_style_endpoint' => true, + ]); + + // Check if file exists + if (! $disk->exists($cleanPath)) { + $this->dispatch('error', 'File not found in S3. Please check the path.'); + + return; + } + + // Get file size + $this->s3FileSize = $disk->size($cleanPath); + + $this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize)); + } catch (\Throwable $e) { + $this->s3FileSize = null; + + return handleError($e, $this); + } + } + + public function restoreFromS3(string $password = ''): bool|string + { + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + + $this->authorize('update', $this->resource); + + if (! ValidationPatterns::isValidContainerName($this->container)) { + $this->dispatch('error', 'Invalid container name.'); + + return true; + } + + if (! $this->s3StorageId || blank($this->s3Path)) { + $this->dispatch('error', 'Please select S3 storage and provide a path first.'); + + return true; + } + + if (is_null($this->s3FileSize)) { + $this->dispatch('error', 'Please check the file first by clicking "Check File".'); + + return true; + } + + if (! $this->server) { + $this->dispatch('error', 'Server not found. Please refresh the page.'); + + return true; + } + + try { + $this->importRunning = true; + + $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); + + $key = $s3Storage->key; + $secret = $s3Storage->secret; + $bucket = $s3Storage->bucket; + $endpoint = $s3Storage->endpoint; + + // Validate bucket name to prevent command injection + if (! $this->validateBucketName($bucket)) { + $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); + + return true; + } + + // Clean the S3 path + $cleanPath = ltrim($this->s3Path, '/'); + + // Validate the S3 path to prevent command injection + if (! $this->validateS3Path($cleanPath)) { + $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); + + return true; + } + + // Get helper image + $helperImage = config('constants.coolify.helper_image'); + $latestVersion = getHelperVersion(); + $fullImageName = "{$helperImage}:{$latestVersion}"; + + // Get the database destination network + if ($this->resource->getMorphClass() === ServiceDatabase::class) { + $destinationNetwork = $this->resource->service->destination->network ?? 'coolify'; + } else { + $destinationNetwork = $this->resource->destination->network ?? 'coolify'; + } + + // Generate unique names for this operation + $containerName = "s3-restore-{$this->resourceUuid}"; + $helperTmpPath = '/tmp/'.basename($cleanPath); + $serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath); + $containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath); + $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; + + $escapedServerTmpPath = escapeshellarg($serverTmpPath); + $escapedContainerTmpPath = escapeshellarg($containerTmpPath); + $escapedScriptPath = escapeshellarg($scriptPath); + $escapedHelperContainerPath = escapeshellarg("{$containerName}:{$helperTmpPath}"); + $escapedDatabaseContainerTmpPath = escapeshellarg("{$this->container}:{$containerTmpPath}"); + $escapedDatabaseContainerScriptPath = escapeshellarg("{$this->container}:{$scriptPath}"); + $restoreAndCleanupCommand = escapeshellarg("{$escapedScriptPath} && rm -f {$escapedContainerTmpPath} {$escapedScriptPath}"); + + // Prepare all commands in sequence + $commands = []; + + // 1. Clean up any existing helper container and temp files from previous runs + $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; + $commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true"; + $commands[] = "docker exec {$this->container} rm -f {$escapedContainerTmpPath} {$escapedScriptPath} 2>/dev/null || true"; + + // 2. Start helper container on the database network + $commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600"; + + // 3. Configure S3 access in helper container + $escapedEndpoint = escapeshellarg($endpoint); + $escapedKey = escapeshellarg($key); + $escapedSecret = escapeshellarg($secret); + $commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; + + // 4. Check file exists in S3 (bucket and path already validated above) + $escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}"); + $commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}"; + + // 5. Download from S3 to helper container (progress shown by default) + $escapedHelperTmpPath = escapeshellarg($helperTmpPath); + $commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}"; + + // 6. Copy from helper to server, then immediately to database container + $commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}"; + $commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}"; + + // 7. Cleanup helper container and server temp file immediately (no longer needed) + $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; + $commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true"; + + // 8. Build and execute restore command inside database container + $restoreCommand = $this->buildRestoreCommand($containerTmpPath); + + $restoreCommandBase64 = base64_encode($restoreCommand); + $commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$escapedScriptPath}"; + $commands[] = "chmod +x {$escapedScriptPath}"; + $commands[] = "docker cp {$escapedScriptPath} {$escapedDatabaseContainerScriptPath}"; + + // 9. Execute restore and cleanup temp files immediately after completion + $commands[] = "docker exec {$this->container} sh -c {$restoreAndCleanupCommand}"; + $commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; + + // Execute all commands with cleanup event (as safety net for edge cases) + $activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [ + 'containerName' => $containerName, + 'serverTmpPath' => $serverTmpPath, + 'scriptPath' => $scriptPath, + 'containerTmpPath' => $containerTmpPath, + 'container' => $this->container, + 'serverId' => $this->server->id, + ]); + + // Track the activity ID + $this->activityId = $activity->id; + + // Dispatch activity to the monitor and open slide-over + $this->dispatch('activityMonitor', $activity->id); + $this->dispatch('databaserestore'); + $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); + } catch (\Throwable $e) { + $this->importRunning = false; + handleError($e, $this); + + return true; + } + + return true; + } + + public function buildRestoreCommand(string $tmpPath): string + { + $escapedTmpPath = escapeshellarg($tmpPath); + $morphClass = $this->resource->getMorphClass(); + + // Handle ServiceDatabase by checking the database type + if ($morphClass === ServiceDatabase::class) { + $dbType = $this->resource->databaseType(); + if (str_contains($dbType, 'mysql')) { + $morphClass = 'mysql'; + } elseif (str_contains($dbType, 'mariadb')) { + $morphClass = 'mariadb'; + } elseif (str_contains($dbType, 'postgres')) { + $morphClass = 'postgresql'; + } elseif (str_contains($dbType, 'mongo')) { + $morphClass = 'mongodb'; + } + } + + switch ($morphClass) { + case StandaloneMariadb::class: + case 'mariadb': + $restoreCommand = $this->mariadbRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}"; + } else { + $restoreCommand .= " < {$escapedTmpPath}"; + } + break; + case StandaloneMysql::class: + case 'mysql': + $restoreCommand = $this->mysqlRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}"; + } else { + $restoreCommand .= " < {$escapedTmpPath}"; + } + break; + case StandalonePostgresql::class: + case 'postgresql': + $restoreCommand = $this->postgresqlRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}"; + } else { + $restoreCommand .= " {$escapedTmpPath}"; + } + break; + case StandaloneMongodb::class: + case 'mongodb': + $restoreCommand = $this->mongodbRestoreCommand.$escapedTmpPath; + break; + default: + $restoreCommand = ''; + } + + return $restoreCommand; + } +} diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 4bf2165fb..b2d9bce91 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -4,11 +4,9 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneKeydb; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; @@ -42,27 +40,23 @@ class General extends Component public ?string $customDockerRunOptions = null; - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - public bool $isLogDrainEnabled = false; - public ?Carbon $certificateValidUntil = null; - - public bool $enable_ssl = false; - public bool $isPasswordHiddenForMember = false; - public function getListeners() + public function getListeners(): array { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; + $user = Auth::user(); + if (! $user) { + return []; + } + $team = $user->currentTeam(); + if (! $team) { + return []; + } return [ - "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped', ]; } @@ -77,12 +71,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -90,14 +78,12 @@ public function mount() $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; if ($this->isPasswordHiddenForMember) { $this->keydbPassword = ''; - $this->dbUrl = null; - $this->dbUrlPublic = null; } } protected function rules(): array { - $baseRules = [ + return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'keydbConf' => 'nullable|string', @@ -110,13 +96,8 @@ protected function rules(): array 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', 'isLogDrainEnabled' => 'nullable|boolean', - 'enable_ssl' => 'boolean', ]; - - return $baseRules; } protected function messages(): array @@ -152,11 +133,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->database->enable_ssl = $this->enable_ssl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -169,9 +146,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->enable_ssl = $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } } @@ -220,6 +194,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -228,9 +203,13 @@ public function instantSave() } } - public function databaseProxyStopped() + public function databaseProxyStopped(): void { - $this->syncData(); + $this->database->refresh(); + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; + $this->dispatch('databaseUpdated'); } public function submit() @@ -246,6 +225,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -257,65 +237,6 @@ public function submit() } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates() - ->where('is_ca_certificate', true) - ->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Keydb/StatusInfo.php b/app/Livewire/Project/Database/Keydb/StatusInfo.php new file mode 100644 index 000000000..1e87461cd --- /dev/null +++ b/app/Livewire/Project/Database/Keydb/StatusInfo.php @@ -0,0 +1,26 @@ +currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - ]; - } - protected function rules(): array { return [ @@ -96,7 +74,6 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', ]; } @@ -135,7 +112,6 @@ protected function messages(): array 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Options', - 'enableSsl' => 'Enable SSL', ]; public function mount() @@ -149,12 +125,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -163,8 +133,6 @@ public function mount() if ($this->isPasswordHiddenForMember) { $this->mariadbRootPassword = ''; $this->mariadbPassword = ''; - $this->db_url = null; - $this->db_url_public = null; } } @@ -186,11 +154,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -206,9 +170,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -244,6 +205,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -280,6 +242,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -288,63 +251,6 @@ public function instantSave() } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mariadb/StatusInfo.php b/app/Livewire/Project/Database/Mariadb/StatusInfo.php new file mode 100644 index 000000000..c6fda37b6 --- /dev/null +++ b/app/Livewire/Project/Database/Mariadb/StatusInfo.php @@ -0,0 +1,21 @@ +currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - ]; - } - protected function rules(): array { return [ @@ -93,8 +69,6 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', - 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-full', ]; } @@ -114,7 +88,6 @@ protected function messages(): array 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.', ] ); } @@ -132,8 +105,6 @@ protected function messages(): array 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -147,12 +118,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -160,8 +125,6 @@ public function mount() $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; if ($this->isPasswordHiddenForMember) { $this->mongoInitdbRootPassword = ''; - $this->db_url = null; - $this->db_url_public = null; } } @@ -182,12 +145,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -202,10 +160,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->sslMode = $this->database->ssl_mode; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -244,6 +198,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -280,6 +235,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -288,68 +244,6 @@ public function instantSave() } } - public function updatedSslMode() - { - $this->instantSaveSSL(); - } - - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mongodb/StatusInfo.php b/app/Livewire/Project/Database/Mongodb/StatusInfo.php new file mode 100644 index 000000000..a92a682c9 --- /dev/null +++ b/app/Livewire/Project/Database/Mongodb/StatusInfo.php @@ -0,0 +1,51 @@ + ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'], + 'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'], + 'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'], + 'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'], + ]; + } + + protected function sslModeHelper(): string + { + return 'Choose the SSL verification mode for MongoDB connections'; + } + + protected function afterRefresh(): void + { + $this->sslMode = $this->database->ssl_mode; + } + + protected function applyExtraSslAttributes(): void + { + $this->database->ssl_mode = $this->sslMode; + } + + public function updatedSslMode(): void + { + $this->instantSaveSSL(); + } +} diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 49f204186..1adfe2ea7 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -4,14 +4,11 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneMysql; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -50,29 +47,8 @@ class General extends Component public ?string $customDockerRunOptions = null; - public bool $enableSsl = false; - - public ?string $sslMode = null; - - public ?string $db_url = null; - - public ?string $db_url_public = null; - - public ?Carbon $certificateValidUntil = null; - public bool $isPasswordHiddenForMember = false; - public function getListeners() - { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - ]; - } - protected function rules(): array { return [ @@ -98,8 +74,6 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', - 'sslMode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY', ]; } @@ -120,7 +94,6 @@ protected function messages(): array 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.', ] ); } @@ -139,8 +112,6 @@ protected function messages(): array 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -154,12 +125,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -168,8 +133,6 @@ public function mount() if ($this->isPasswordHiddenForMember) { $this->mysqlRootPassword = ''; $this->mysqlPassword = ''; - $this->db_url = null; - $this->db_url_public = null; } } @@ -191,12 +154,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -212,10 +170,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->sslMode = $this->database->ssl_mode; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -251,6 +205,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -287,6 +242,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -295,68 +251,6 @@ public function instantSave() } } - public function updatedSslMode() - { - $this->instantSaveSSL(); - } - - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mysql/StatusInfo.php b/app/Livewire/Project/Database/Mysql/StatusInfo.php new file mode 100644 index 000000000..5fbbc1583 --- /dev/null +++ b/app/Livewire/Project/Database/Mysql/StatusInfo.php @@ -0,0 +1,51 @@ + ['title' => 'Prefer secure connections', 'label' => 'Prefer (secure)'], + 'REQUIRED' => ['title' => 'Require secure connections', 'label' => 'Require (secure)'], + 'VERIFY_CA' => ['title' => 'Verify CA certificate', 'label' => 'Verify CA (secure)'], + 'VERIFY_IDENTITY' => ['title' => 'Verify full certificate', 'label' => 'Verify Full (secure)'], + ]; + } + + protected function sslModeHelper(): string + { + return 'Choose the SSL verification mode for MySQL connections'; + } + + protected function afterRefresh(): void + { + $this->sslMode = $this->database->ssl_mode; + } + + protected function applyExtraSslAttributes(): void + { + $this->database->ssl_mode = $this->sslMode; + } + + public function updatedSslMode(): void + { + $this->instantSaveSSL(); + } +} diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 38cb770e6..8993cc251 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -4,14 +4,11 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandalonePostgresql; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -54,34 +51,16 @@ class General extends Component public ?string $customDockerRunOptions = null; - public bool $enableSsl = false; - - public ?string $sslMode = null; - public string $new_filename; public string $new_content; - public ?string $db_url = null; - - public ?string $db_url_public = null; - - public ?Carbon $certificateValidUntil = null; - public bool $isPasswordHiddenForMember = false; - public function getListeners() - { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - 'save_init_script', - 'delete_init_script', - ]; - } + protected $listeners = [ + 'save_init_script', + 'delete_init_script', + ]; protected function rules(): array { @@ -108,8 +87,6 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', - 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full', ]; } @@ -129,7 +106,6 @@ protected function messages(): array 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.', ] ); } @@ -150,8 +126,6 @@ protected function messages(): array 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -165,12 +139,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -178,8 +146,6 @@ public function mount() $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; if ($this->isPasswordHiddenForMember) { $this->postgresPassword = ''; - $this->db_url = null; - $this->db_url_public = null; } } @@ -203,12 +169,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -226,10 +187,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->sslMode = $this->database->ssl_mode; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -252,68 +209,6 @@ public function instantSaveAdvanced() } } - public function updatedSslMode() - { - $this->instantSaveSSL(); - } - - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function instantSave() { try { @@ -339,6 +234,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -502,6 +398,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { diff --git a/app/Livewire/Project/Database/Postgresql/StatusInfo.php b/app/Livewire/Project/Database/Postgresql/StatusInfo.php new file mode 100644 index 000000000..cc27b61bb --- /dev/null +++ b/app/Livewire/Project/Database/Postgresql/StatusInfo.php @@ -0,0 +1,52 @@ + ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'], + 'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'], + 'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'], + 'verify-ca' => ['title' => 'Verify CA certificate', 'label' => 'verify-ca (secure)'], + 'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'], + ]; + } + + protected function sslModeHelper(): string + { + return 'Choose the SSL verification mode for PostgreSQL connections'; + } + + protected function afterRefresh(): void + { + $this->sslMode = $this->database->ssl_mode; + } + + protected function applyExtraSslAttributes(): void + { + $this->database->ssl_mode = $this->sslMode; + } + + public function updatedSslMode(): void + { + $this->instantSaveSSL(); + } +} diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index 7d1979435..d431b1506 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -4,14 +4,11 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneRedis; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -48,27 +45,11 @@ class General extends Component public string $redisVersion; - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - - public bool $enableSsl = false; - - public ?Carbon $certificateValidUntil = null; - public bool $isPasswordHiddenForMember = false; - public function getListeners() - { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - 'envsUpdated' => 'refresh', - ]; - } + protected $listeners = [ + 'envsUpdated' => 'refresh', + ]; protected function rules(): array { @@ -89,7 +70,6 @@ protected function rules(): array 'redisPassword' => ValidationPatterns::databasePasswordRules( enforcePattern: $this->redisPassword !== $this->database->redis_password, ), - 'enableSsl' => 'boolean', ]; } @@ -124,7 +104,6 @@ protected function messages(): array 'customDockerRunOptions' => 'Custom Docker Options', 'redisUsername' => 'Redis Username', 'redisPassword' => 'Redis Password', - 'enableSsl' => 'Enable SSL', ]; public function mount() @@ -138,12 +117,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -151,8 +124,6 @@ public function mount() $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; if ($this->isPasswordHiddenForMember) { $this->redisPassword = ''; - $this->dbUrl = null; - $this->dbUrlPublic = null; } } @@ -170,11 +141,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -186,9 +153,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; $this->redisVersion = $this->database->getRedisVersion(); $this->redisUsername = $this->database->redis_username; $this->redisPassword = $this->database->redis_password; @@ -236,6 +200,7 @@ public function submit() ); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -268,6 +233,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -276,63 +242,6 @@ public function instantSave() } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Redis/StatusInfo.php b/app/Livewire/Project/Database/Redis/StatusInfo.php new file mode 100644 index 000000000..2e784e2c0 --- /dev/null +++ b/app/Livewire/Project/Database/Redis/StatusInfo.php @@ -0,0 +1,21 @@ +environmentName = Environment::findOrFail($this->environment_id)->name; - $this->parameters = get_route_parameters(); - } catch (\Exception $e) { - return handleError($e, $this); - } + $this->parameters = get_route_parameters(); + $this->environmentName = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id)->name; } public function delete() @@ -34,7 +32,7 @@ public function delete() $this->validate([ 'environment_id' => 'required|int', ]); - $environment = Environment::findOrFail($this->environment_id); + $environment = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id); $this->authorize('delete', $environment); if ($environment->isEmpty()) { diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index b89ce2c6a..737806cb8 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -5,6 +5,7 @@ use App\Models\Application; use App\Models\Project; use App\Services\DockerImageParser; +use App\Support\ValidationPatterns; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -81,8 +82,8 @@ public function updatedImageName(): void public function submit() { $this->validate([ - 'imageName' => ['required', 'string'], - 'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'], + 'imageName' => ValidationPatterns::dockerImageNameRules(required: true), + 'imageTag' => ValidationPatterns::dockerImageTagRules(), 'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'], ]); diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 0ce1bd1a2..1c9c8e896 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -9,6 +9,7 @@ use App\Support\ValidationPatterns; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Route; +use Livewire\Attributes\Locked; use Livewire\Component; class GithubPrivateRepository extends Component @@ -29,6 +30,7 @@ class GithubPrivateRepository extends Component public int $selected_repository_id; + #[Locked] public int $selected_github_app_id; public string $selected_repository_owner; @@ -37,8 +39,6 @@ class GithubPrivateRepository extends Component public string $selected_branch_name = 'main'; - public string $token; - public $repositories; public int $total_repositories_count = 0; @@ -71,7 +71,10 @@ public function mount() $this->parameters = get_route_parameters(); $this->query = request()->query(); $this->repositories = $this->branches = collect(); - $this->github_apps = GithubApp::private(); + $this->github_apps = GithubApp::ownedByCurrentTeam() + ->where('is_public', false) + ->whereNotNull('app_id') + ->get(); } public function updatedSelectedRepositoryId(): void @@ -96,22 +99,25 @@ public function updatedBuildPack() } } - public function loadRepositories($github_app_id) + public function loadRepositories(int $github_app_id): void { $this->repositories = collect(); $this->branches = collect(); $this->total_branches_count = 0; $this->page = 1; $this->selected_github_app_id = $github_app_id; - $this->github_app = GithubApp::where('id', $github_app_id)->first(); - $this->token = generateGithubInstallationToken($this->github_app); - $repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page); + $this->github_app = GithubApp::ownedByCurrentTeam() + ->where('is_public', false) + ->whereNotNull('app_id') + ->findOrFail($github_app_id); + $token = generateGithubInstallationToken($this->github_app); + $repositories = loadRepositoryByPage($this->github_app, $token, $this->page); $this->total_repositories_count = $repositories['total_count']; $this->repositories = $this->repositories->concat(collect($repositories['repositories'])); if ($this->repositories->count() < $this->total_repositories_count) { while ($this->repositories->count() < $this->total_repositories_count) { $this->page++; - $repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page); + $repositories = loadRepositoryByPage($this->github_app, $token, $this->page); $this->total_repositories_count = $repositories['total_count']; $this->repositories = $this->repositories->concat(collect($repositories['repositories'])); } @@ -142,7 +148,9 @@ public function loadBranches() protected function loadBranchByPage() { - $response = Http::GitHub($this->github_app->api_url, $this->token) + $token = generateGithubInstallationToken($this->github_app); + + $response = Http::GitHub($this->github_app->api_url, $token) ->timeout(20) ->retry(3, 200, throw: false) ->get("/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches", [ diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index 2d69ceb12..ac2b39bb8 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -4,7 +4,6 @@ use App\Models\Service; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class Configuration extends Component @@ -27,16 +26,10 @@ class Configuration extends Component public array $parameters; - public function getListeners() - { - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked', - 'refreshServices' => 'refreshServices', - 'refresh' => 'refreshServices', - ]; - } + protected $listeners = [ + 'refreshServices' => 'refreshServices', + 'refresh' => 'refreshServices', + ]; public function render() { @@ -105,18 +98,4 @@ public function restartDatabase($id) return handleError($e, $this); } } - - public function serviceChecked() - { - try { - $this->service->applications->each(function ($application) { - $application->refresh(); - }); - $this->service->databases->each(function ($database) { - $database->refresh(); - }); - } catch (\Exception $e) { - return handleError($e, $this); - } - } } diff --git a/app/Livewire/Project/Service/DatabaseBackups.php b/app/Livewire/Project/Service/DatabaseBackups.php index 826a6c1ff..883441ecb 100644 --- a/app/Livewire/Project/Service/DatabaseBackups.php +++ b/app/Livewire/Project/Service/DatabaseBackups.php @@ -28,10 +28,16 @@ public function mount() try { $this->parameters = get_route_parameters(); $this->query = request()->query(); - $this->service = Service::whereUuid($this->parameters['service_uuid'])->first(); - if (! $this->service) { - return redirect()->route('dashboard'); - } + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', $this->parameters['project_uuid']) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') + ->where('uuid', $this->parameters['environment_uuid']) + ->firstOrFail(); + $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail(); $this->authorize('view', $this->service); $this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first(); diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index 20502393b..34bb46ff1 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -30,6 +30,8 @@ class Heading extends Component public function mount() { + $this->authorizeService('view'); + if (str($this->service->status)->contains('running') && is_null($this->service->config_hash)) { $this->service->isConfigurationChanged(true); $this->dispatch('configurationChanged'); @@ -50,6 +52,8 @@ public function getListeners() public function checkStatus() { + $this->authorizeService('view'); + if ($this->service->server->isFunctional()) { GetContainersStatus::dispatch($this->service->server); } else { @@ -64,6 +68,8 @@ public function manualCheckStatus() public function serviceChecked() { + $this->authorizeService('view'); + try { $this->service->applications->each(function ($application) { $application->refresh(); @@ -85,6 +91,8 @@ public function serviceChecked() public function checkDeployments() { + $this->authorizeService('view'); + try { $activity = Activity::where('properties->type_uuid', $this->service->uuid)->latest()->first(); $status = data_get($activity, 'properties.status'); @@ -103,7 +111,7 @@ public function checkDeployments() public function start() { try { - $this->authorize('deploy', $this->service); + $this->authorizeService('deploy'); $activity = StartService::run($this->service, pullLatestImages: true); $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); @@ -115,7 +123,7 @@ public function start() public function forceDeploy() { try { - $this->authorize('deploy', $this->service); + $this->authorizeService('deploy'); $activities = Activity::where('properties->type_uuid', $this->service->uuid) ->where(function ($q) { $q->where('properties->status', ProcessStatus::IN_PROGRESS->value) @@ -136,7 +144,7 @@ public function forceDeploy() public function stop() { try { - $this->authorize('stop', $this->service); + $this->authorizeService('stop'); StopService::dispatch($this->service, false, $this->docker_cleanup); } catch (\Throwable $e) { return handleError($e, $this); @@ -146,7 +154,7 @@ public function stop() public function restart() { try { - $this->authorize('deploy', $this->service); + $this->authorizeService('deploy'); $this->checkDeployments(); if ($this->isDeploymentProgress) { $this->dispatch('error', 'There is a deployment in progress.'); @@ -164,7 +172,7 @@ public function restart() public function pullAndRestartEvent() { try { - $this->authorize('deploy', $this->service); + $this->authorizeService('deploy'); $this->checkDeployments(); if ($this->isDeploymentProgress) { $this->dispatch('error', 'There is a deployment in progress.'); @@ -179,6 +187,15 @@ public function pullAndRestartEvent() } } + private function authorizeService(string $ability): void + { + $this->service = Service::ownedByCurrentTeam() + ->whereKey($this->service->getKey()) + ->firstOrFail(); + + $this->authorize($ability, $this->service); + } + public function render() { return view('livewire.project.service.heading', [ diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index cb2d977bc..12c0edbca 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -108,10 +108,16 @@ public function mount() $this->parameters = get_route_parameters(); $this->query = request()->query(); $this->currentRoute = request()->route()->getName(); - $this->service = Service::whereUuid($this->parameters['service_uuid'])->first(); - if (! $this->service) { - return redirect()->route('dashboard'); - } + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', $this->parameters['project_uuid']) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') + ->where('uuid', $this->parameters['environment_uuid']) + ->firstOrFail(); + $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail(); $this->authorize('view', $this->service); $service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first(); if ($service) { diff --git a/app/Livewire/Project/Service/ResourceCard.php b/app/Livewire/Project/Service/ResourceCard.php new file mode 100644 index 000000000..fd27f60c3 --- /dev/null +++ b/app/Livewire/Project/Service/ResourceCard.php @@ -0,0 +1,66 @@ +currentTeam(); + if (! $team) { + return []; + } + + return [ + "echo-private:team.{$team->id},ServiceChecked" => 'refreshResource', + ]; + } + + public function refreshResource(): void + { + $this->resource->refresh(); + } + + public function restart(): void + { + try { + $this->authorize('update', $this->service); + $this->resource->restart(); + $message = $this->resource instanceof ServiceApplication + ? 'Service application restarted successfully.' + : 'Service database restarted successfully.'; + $this->dispatch('success', $message); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function render(): View + { + return view('livewire.project.service.resource-card', [ + 'isApplication' => $this->resource instanceof ServiceApplication, + 'isDatabase' => $this->resource instanceof ServiceDatabase, + ]); + } +} diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 8fcca452a..51965e81f 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -116,16 +116,23 @@ public function redeploy(int $network_id, int $server_id) public function promote(int $network_id, int $server_id) { try { + $server = Server::ownedByCurrentTeam()->findOrFail($server_id); + $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id); $this->authorize('update', $this->resource); - $main_destination = $this->resource->destination; - $this->resource->update([ - 'destination_id' => $network_id, - 'destination_type' => StandaloneDocker::class, - ]); - $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]); - $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]); - $this->refreshServers(); + + $this->resource->getConnection()->transaction(function () use ($network, $server) { + $main_destination = $this->resource->destination; + $this->resource->update([ + 'destination_id' => $network->id, + 'destination_type' => StandaloneDocker::class, + ]); + $this->resource->additional_networks() + ->wherePivot('server_id', $server->id) + ->detach($network->id); + $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]); + }); $this->resource->refresh(); + $this->refreshServers(); } catch (\Throwable $e) { return handleError($e, $this); } @@ -141,8 +148,11 @@ public function refreshServers() public function addServer(int $network_id, int $server_id) { try { + $server = Server::ownedByCurrentTeam()->findOrFail($server_id); + $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id); $this->authorize('update', $this->resource); - $this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]); + + $this->resource->additional_networks()->attach($network->id, ['server_id' => $server->id]); $this->dispatch('refresh'); } catch (\Throwable $e) { return handleError($e, $this); @@ -164,7 +174,9 @@ public function removeServer(int $network_id, int $server_id, $password, $select } $server = Server::ownedByCurrentTeam()->findOrFail($server_id); StopApplicationOneServer::run($this->resource, $server); - $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]); + $this->resource->additional_networks() + ->wherePivot('server_id', $server_id) + ->detach($network_id); $this->loadData(); $this->dispatch('refresh'); ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id')); diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index bbc2b3e66..db65cdaad 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -12,6 +12,8 @@ class Terminal extends Component { public bool $hasShell = true; + public bool $isTerminalConnected = false; + private function checkShellAvailability(Server $server, string $container): bool { $escapedContainer = escapeshellarg($container); @@ -65,12 +67,20 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid) $dockerCommand = "sudo {$dockerCommand}"; } - $command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand); + $command = SshMultiplexingHelper::generateSshCommand( + $server, + $dockerCommand, + commandTimeout: (int) config('constants.terminal.command_timeout') + ); } else { $shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '. 'if [ -f ~/.profile ]; then . ~/.profile; fi && '. 'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi'; - $command = SshMultiplexingHelper::generateSshCommand($server, $shellCommand); + $command = SshMultiplexingHelper::generateSshCommand( + $server, + $shellCommand, + commandTimeout: (int) config('constants.terminal.command_timeout') + ); } // ssh command is sent back to frontend then to websocket // this is done because the websocket connection is not available here @@ -84,6 +94,23 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid) $this->dispatch('send-back-command', $command); } + #[On('terminalConnected')] + public function markTerminalConnected(): void + { + $this->isTerminalConnected = true; + } + + #[On('terminalDisconnected')] + public function markTerminalDisconnected(): void + { + $this->isTerminalConnected = false; + } + + public function keepTerminalPageAlive(): void + { + $this->isTerminalConnected = true; + } + public function render() { return view('livewire.project.shared.terminal'); diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 08503359e..bf201e257 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -5,6 +5,7 @@ use App\Models\InstanceSettings; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Laravel\Sanctum\PersonalAccessToken; +use Livewire\Attributes\Locked; use Livewire\Component; class ApiTokens extends Component @@ -29,12 +30,16 @@ class ApiTokens extends Component public $isApiEnabled; + #[Locked] public bool $canUseRootPermissions = false; + #[Locked] public bool $canUseWritePermissions = false; + #[Locked] public bool $canUseDeployPermissions = false; + #[Locked] public bool $canUseSensitivePermissions = false; public function render() @@ -59,7 +64,7 @@ private function getTokens() public function updatedPermissions($permissionToUpdate) { - // Re-evaluate policies fresh — never trust stored snapshot booleans + // Re-evaluate policies fresh — never trust stored snapshot booleans. if ($permissionToUpdate == 'root' && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use root permissions.'); $this->permissions = array_diff($this->permissions, ['root']); @@ -67,7 +72,7 @@ public function updatedPermissions($permissionToUpdate) return; } - if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) { + if (in_array($permissionToUpdate, ['write', 'write:sensitive'], true) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use write permissions.'); $this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']); @@ -90,7 +95,7 @@ public function updatedPermissions($permissionToUpdate) if ($permissionToUpdate == 'root') { $this->permissions = ['root']; - } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions)) { + } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) { $this->permissions[] = 'read'; } elseif ($permissionToUpdate == 'deploy') { $this->permissions = ['deploy']; @@ -110,7 +115,7 @@ public function addNewToken() // Re-evaluate policies fresh against the current authenticated user. // Never trust $this->canUse* booleans — they come from the Livewire // snapshot which can be replayed from another user's session. - if (in_array('root', $this->permissions) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) { + if (in_array('root', $this->permissions, true) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) { throw new \Exception('You do not have permission to create tokens with root permissions.'); } @@ -118,11 +123,11 @@ public function addNewToken() throw new \Exception('You do not have permission to create tokens with write permissions.'); } - if (in_array('deploy', $this->permissions) && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) { + if (in_array('deploy', $this->permissions, true) && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) { throw new \Exception('You do not have permission to create tokens with deploy permissions.'); } - if (in_array('read:sensitive', $this->permissions) && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) { + if (in_array('read:sensitive', $this->permissions, true) && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) { throw new \Exception('You do not have permission to create tokens with read:sensitive permissions.'); } diff --git a/app/Livewire/Server/Destinations.php b/app/Livewire/Server/Destinations.php index 622bc1d1e..7b5d6f9da 100644 --- a/app/Livewire/Server/Destinations.php +++ b/app/Livewire/Server/Destinations.php @@ -45,7 +45,7 @@ public function add($name) } else { SwarmDocker::create([ 'name' => $this->server->name.'-'.$name, - 'network' => $this->name, + 'network' => $name, 'server_id' => $this->server->id, ]); } diff --git a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php index f7db1257c..4ea21e4ae 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php @@ -29,12 +29,11 @@ public function delete(string $fileName) // Decode filename: pipes are used to encode dots for Livewire property binding // (e.g., 'my|service.yaml' -> 'my.service.yaml') - // This must happen BEFORE validation because validateShellSafePath() correctly - // rejects pipe characters as dangerous shell metacharacters + // This must happen BEFORE validation because validateFilenameSafe() + // rejects pipe characters through validateShellSafePath(). $file = str_replace('|', '.', $fileName); - // Validate filename to prevent command injection - validateShellSafePath($file, 'proxy configuration filename'); + validateFilenameSafe($file, 'proxy configuration filename'); if ($proxy_type === 'CADDY' && $file === 'Caddyfile') { $this->dispatch('error', 'Cannot delete Caddyfile.'); diff --git a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php index 31a1dfc7e..481d89c78 100644 --- a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php +++ b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php @@ -43,8 +43,7 @@ public function addDynamicConfiguration() 'value' => 'required', ]); - // Additional security validation to prevent command injection - validateShellSafePath($this->fileName, 'proxy configuration filename'); + validateFilenameSafe($this->fileName, 'proxy configuration filename'); if (data_get($this->parameters, 'server_uuid')) { $this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first(); diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index a4b35891b..06aebd8f8 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -93,7 +93,9 @@ public function handleSentinelRestarted($event) { if ($event['serverUuid'] === $this->server->uuid) { $this->server->refresh(); - $this->syncData(); + // Only refresh display-only state; never re-sync text-input properties + // (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695). + $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; $this->dispatch('success', 'Sentinel has been restarted successfully.'); } } diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index c55b4ddb8..dc6a775a0 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -277,7 +277,9 @@ public function handleSentinelRestarted($event) // Only refresh if the event is for this server if (isset($event['serverUuid']) && $event['serverUuid'] === $this->server->uuid) { $this->server->refresh(); - $this->syncData(); + // Only refresh display-only state; never re-sync text-input properties + // (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695). + $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; $this->dispatch('success', 'Sentinel has been restarted successfully.'); } } @@ -462,12 +464,15 @@ public function handleServerValidated($event = null) return; } - // Refresh server data + // Refresh server data and only the display-only state that validation produces. + // Never re-sync text-input properties via syncData() — would clobber any + // unsaved typing (see coolify#6062 / #6354 / #9695). $this->server->refresh(); - $this->syncData(); - - // Update validation state + $this->server->settings->refresh(); $this->isValidating = $this->server->is_validating ?? false; + $this->validationLogs = $this->server->validation_logs; + $this->isReachable = $this->server->settings->is_reachable; + $this->isUsable = $this->server->settings->is_usable; // Reload Hetzner tokens in case the linking section should now be shown $this->loadHetznerTokens(); diff --git a/app/Livewire/SettingsDropdown.php b/app/Livewire/SettingsDropdown.php index 7afa763df..cd41197cb 100644 --- a/app/Livewire/SettingsDropdown.php +++ b/app/Livewire/SettingsDropdown.php @@ -11,6 +11,8 @@ class SettingsDropdown extends Component { public $showWhatsNewModal = false; + public string $trigger = 'preferences'; + public function getUnreadCountProperty() { return Auth::user()->getUnreadChangelogCount(); diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index d6537069c..1470b95db 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -7,7 +7,9 @@ use App\Models\PrivateKey; use App\Rules\SafeExternalUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Str; use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; @@ -19,6 +21,10 @@ class Change extends Component public string $webhook_endpoint = ''; + public string $custom_webhook_endpoint = ''; + + public bool $use_custom_webhook_endpoint = false; + public ?string $ipv4 = null; public ?string $ipv6 = null; @@ -72,6 +78,10 @@ class Change extends Component public $privateKeys; + public string $manifestState = ''; + + public string $activeTab = 'general'; + protected function rules(): array { return [ @@ -91,6 +101,9 @@ protected function rules(): array 'metadata' => 'nullable|string', 'pullRequests' => 'nullable|string', 'privateKeyId' => 'nullable|int', + 'webhook_endpoint' => ['required', 'string', 'url'], + 'custom_webhook_endpoint' => ['nullable', 'string', 'url'], + 'use_custom_webhook_endpoint' => ['required', 'bool'], ]; } @@ -147,6 +160,24 @@ private function syncData(bool $toModel = false): void } } + private function githubAppSetupStateCacheKey(string $state): string + { + return 'github-app-setup-state:'.hash('sha256', $state); + } + + private function createGithubAppSetupState(string $action): string + { + $state = Str::random(64); + + Cache::put($this->githubAppSetupStateCacheKey($state), [ + 'action' => $action, + 'github_app_id' => $this->github_app->id, + 'team_id' => $this->github_app->team_id, + ], now()->addMinutes(60)); + + return $state; + } + public function checkPermissions() { try { @@ -211,6 +242,7 @@ public function mount() // Override name with kebab case for display $this->name = str($this->github_app->name)->kebab(); $this->fqdn = $settings->fqdn; + $this->manifestState = $this->createGithubAppSetupState('manifest'); if ($settings->public_ipv4) { $this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port'); @@ -240,10 +272,18 @@ public function mount() } } $this->parameters = get_route_parameters(); + $routeName = request()->route()?->getName(); + if ($routeName === 'source.github.permissions') { + $this->activeTab = 'permissions'; + } elseif ($routeName === 'source.github.resources') { + $this->activeTab = 'resources'; + } else { + $this->activeTab = 'general'; + } if (isCloud() && ! isDev()) { $this->webhook_endpoint = config('app.url'); } else { - $this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? ''; + $this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? $this->ipv6 ?? config('app.url') ?? ''; $this->is_system_wide = $this->github_app->is_system_wide; } } catch (\Throwable $e) { diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index b1f692365..97d492d70 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -2,6 +2,7 @@ namespace App\Livewire\Team; +use App\Actions\User\RevokeUserTeamTokens; use App\Enums\Role; use App\Models\User; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -23,7 +24,9 @@ public function makeAdmin() || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { throw new \Exception('You are not authorized to perform this action.'); } - $this->member->teams()->updateExistingPivot(currentTeam()->id, ['role' => Role::ADMIN->value]); + $teamId = currentTeam()->id; + $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]); + RevokeUserTeamTokens::forUserTeam($this->member, $teamId); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -39,7 +42,9 @@ public function makeOwner() || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { throw new \Exception('You are not authorized to perform this action.'); } - $this->member->teams()->updateExistingPivot(currentTeam()->id, ['role' => Role::OWNER->value]); + $teamId = currentTeam()->id; + $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]); + RevokeUserTeamTokens::forUserTeam($this->member, $teamId); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -55,7 +60,9 @@ public function makeReadonly() || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { throw new \Exception('You are not authorized to perform this action.'); } - $this->member->teams()->updateExistingPivot(currentTeam()->id, ['role' => Role::MEMBER->value]); + $teamId = currentTeam()->id; + $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]); + RevokeUserTeamTokens::forUserTeam($this->member, $teamId); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -73,6 +80,7 @@ public function remove() } $teamId = currentTeam()->id; $this->member->teams()->detach(currentTeam()); + RevokeUserTeamTokens::forUserTeam($this->member, $teamId); // Clear cache for the removed user - both old and new key formats Cache::forget("team:{$this->member->id}"); Cache::forget("user:{$this->member->id}:team:{$teamId}"); diff --git a/app/Mcp/Concerns/ResolvesTeam.php b/app/Mcp/Concerns/ResolvesTeam.php index f75219fcf..f6d82453a 100644 --- a/app/Mcp/Concerns/ResolvesTeam.php +++ b/app/Mcp/Concerns/ResolvesTeam.php @@ -28,8 +28,14 @@ protected function ensureAbility(Request $request, string $ability = 'read'): ?R protected function resolveTeamId(Request $request): ?int { - $token = $request->user()?->currentAccessToken(); + $user = $request->user(); + $token = $user?->currentAccessToken(); + $teamId = $token?->team_id; - return $token?->team_id; + if (! $user || is_null($teamId) || ! $user->teams()->where('teams.id', $teamId)->exists()) { + return null; + } + + return (int) $teamId; } } diff --git a/app/Models/Application.php b/app/Models/Application.php index 97b257752..3905281d8 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1188,17 +1188,20 @@ public function pendingDeploymentConfigurationDiff(): ConfigurationDiff $currentSnapshot = $this->deploymentConfigurationSnapshot(); $lastDeployment = $this->get_last_successful_deployment(); - if ($lastDeployment?->configuration_snapshot) { - return app(ConfigurationDiffer::class)->diff($lastDeployment->configuration_snapshot, $currentSnapshot); + $previousSnapshot = $lastDeployment?->configuration_snapshot; + + if (! $previousSnapshot) { + $oldConfigHash = data_get($this, 'config_hash'); + $hasLegacyChange = $oldConfigHash === null || $oldConfigHash !== $this->legacyConfigurationHash(); + + if (! $hasLegacyChange) { + return ConfigurationDiff::unchanged(); + } + + $previousSnapshot = []; } - $oldConfigHash = data_get($this, 'config_hash'); - - if ($oldConfigHash === null) { - return ConfigurationDiff::legacy(true); - } - - return ConfigurationDiff::legacy($oldConfigHash !== $this->legacyConfigurationHash()); + return app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot); } public function hasPendingDeploymentConfigurationChanges(): bool @@ -1276,15 +1279,19 @@ public function dirOnServer() return application_configuration_dir()."/{$this->uuid}"; } - public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $git_ssh_command = null) + public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $gitSshCommand = null, ?string $git_ssh_command = null, ?string $gitConfigOptions = null) { $baseDir = $this->generateBaseDir($deployment_uuid); $escapedBaseDir = escapeshellarg($baseDir); $isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false; + $gitCommand = $gitConfigOptions ? "git {$gitConfigOptions}" : 'git'; - // Use the full GIT_SSH_COMMAND (including -i for SSH key and port options) when provided, - // so that git fetch, submodule update, and lfs pull can authenticate the same way as git clone. - $sshCommand = $git_ssh_command ?? 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"'; + $resolvedGitSshCommand = $git_ssh_command ?? $gitSshCommand; + $sshCommand = $resolvedGitSshCommand + ? (str_starts_with($resolvedGitSshCommand, 'GIT_SSH_COMMAND=') + ? $resolvedGitSshCommand + : 'GIT_SSH_COMMAND="'.$resolvedGitSshCommand.'"') + : 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"'; // Use the explicitly passed commit (e.g. from rollback), falling back to the application's git_commit_sha. // Invalid refs will cause the git checkout/fetch command to fail on the remote server. @@ -1295,9 +1302,9 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_ // If shallow clone is enabled and we need a specific commit, // we need to fetch that specific commit with depth=1 if ($isShallowCloneEnabled) { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} fetch --depth=1 origin {$escapedCommit} && {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } else { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } } if ($this->settings->is_git_submodules_enabled) { @@ -1308,10 +1315,10 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_ } // Add shallow submodules flag if shallow clone is enabled $submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : ''; - $git_clone_command = "{$git_clone_command} git submodule sync && {$sshCommand} git submodule update --init --recursive {$submoduleFlags}; fi"; + $git_clone_command = "{$git_clone_command} {$gitCommand} submodule sync && {$sshCommand} {$gitCommand} submodule update --init --recursive {$submoduleFlags}; fi"; } if ($this->settings->is_git_lfs_enabled) { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git lfs pull"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} lfs pull"; } return $git_clone_command; @@ -1552,6 +1559,11 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $github_access_token = generateGithubInstallationToken($this->source); $encodedToken = rawurlencode($github_access_token); + + // Rewrite same-host HTTPS URLs only for these git commands so submodules can authenticate without persisting credentials. + $gitConfigOption = '-c '.escapeshellarg("url.{$source_html_url_scheme}://x-access-token:{$encodedToken}@{$source_html_url_host}/.insteadOf={$source_html_url_scheme}://{$source_html_url_host}/"); + $git_clone_command = str_replace('git clone', "git {$gitConfigOption} clone", $git_clone_command); + if ($exec_in_docker) { $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git"; $escapedRepoUrl = escapeshellarg($repoUrl); @@ -1564,7 +1576,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $fullRepoUrl = $repoUrl; } if (! $only_checkout) { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOption); } if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); @@ -1575,7 +1587,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req if ($pull_request_id !== 0) { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; - $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name); + $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOption ?? null); $escapedPrBranch = escapeshellarg($branch); if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command")); @@ -1600,12 +1612,13 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $private_key = base64_encode($private_key); $gitlabPort = $gitlabSource->custom_port ?? 22; $escapedCustomRepository = escapeshellarg($customRepository); - $gitlabSshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\""; - $git_clone_command_base = "{$gitlabSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + $gitlabSshCommand = "ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"; + $gitlabGitSshCommand = "GIT_SSH_COMMAND=\"{$gitlabSshCommand}\""; + $git_clone_command_base = "{$gitlabGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $gitlabSshCommand); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand); } if ($exec_in_docker) { $commands = collect([ @@ -1628,7 +1641,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$gitlabGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $gitlabSshCommand); } if ($exec_in_docker) { @@ -1671,12 +1684,13 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } $private_key = base64_encode($private_key); $escapedCustomRepository = escapeshellarg($customRepository); - $deployKeySshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\""; - $git_clone_command_base = "{$deployKeySshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + $deployKeySshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"; + $deployKeyGitSshCommand = "GIT_SSH_COMMAND=\"{$deployKeySshCommand}\""; + $git_clone_command_base = "{$deployKeyGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $deployKeySshCommand); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand); } if ($exec_in_docker) { $commands = collect([ @@ -1699,7 +1713,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1707,14 +1721,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" ".$this->buildGitCheckoutCommand($commit, $deployKeySshCommand); } } @@ -1735,6 +1749,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $escapedCustomRepository = escapeshellarg($customRepository); $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit); + $otherSshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"; if ($pull_request_id !== 0) { if ($git_type === 'gitlab') { @@ -1744,7 +1759,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1752,14 +1767,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand); } } @@ -2008,13 +2023,15 @@ public function fqdns(): Attribute ); } - protected function buildGitCheckoutCommand($target): string + protected function buildGitCheckoutCommand($target, ?string $gitSshCommand = null, ?string $gitConfigOptions = null): string { $escapedTarget = escapeshellarg($target); - $command = "git checkout {$escapedTarget}"; + $gitCommand = $gitConfigOptions ? "git {$gitConfigOptions}" : 'git'; + $command = "{$gitCommand} checkout {$escapedTarget}"; if ($this->settings->is_git_submodules_enabled) { - $command .= ' && git submodule update --init --recursive'; + $sshCommand = $gitSshCommand ?? 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'; + $command .= " && GIT_SSH_COMMAND=\"{$sshCommand}\" {$gitCommand} submodule update --init --recursive"; } return $command; diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index 54bbb3f7d..e5032d2d0 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -73,26 +73,6 @@ public static function ownedByCurrentTeam() }); } - public static function public() - { - return GithubApp::where(function ($query) { - $query->where(function ($q) { - $q->where('team_id', currentTeam()->id) - ->orWhere('is_system_wide', true); - })->where('is_public', true); - })->whereNotNull('app_id')->get(); - } - - public static function private() - { - return GithubApp::where(function ($query) { - $query->where(function ($q) { - $q->where('team_id', currentTeam()->id) - ->orWhere('is_system_wide', true); - })->where('is_public', false); - })->whereNotNull('app_id')->get(); - } - public function team() { return $this->belongsTo(Team::class); diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index bcbbe526e..3b344dfff 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -14,7 +14,12 @@ class S3Storage extends BaseModel { use HasFactory, HasSafeStringAttribute; + private const CONNECTION_TIMEOUT_SECONDS = 15; + + private const REQUEST_TIMEOUT_SECONDS = 15; + protected $fillable = [ + 'team_id', 'name', 'description', 'region', @@ -157,6 +162,10 @@ public function testConnection(bool $shouldSave = false) 'bucket' => $this['bucket'], 'endpoint' => $this['endpoint'], 'use_path_style_endpoint' => true, + 'http' => [ + 'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS, + 'timeout' => self::REQUEST_TIMEOUT_SECONDS, + ], ]); // Test the connection by listing files with ListObjectsV2 (S3) $disk->files(); @@ -164,12 +173,13 @@ public function testConnection(bool $shouldSave = false) $this->unusable_email_sent = false; $this->is_usable = true; } catch (\Throwable $e) { + $exception = $this->toUserFriendlyConnectionException($e); $this->is_usable = false; if ($this->unusable_email_sent === false && is_transactional_emails_enabled()) { try { $mail = new MailMessage; $mail->subject('Coolify: S3 Storage Connection Error'); - $mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $e->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]); + $mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $exception->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]); // Load the team with its members and their roles explicitly $team = $this->team()->with(['members' => function ($query) { @@ -187,11 +197,25 @@ public function testConnection(bool $shouldSave = false) } } - throw $e; + throw $exception; } finally { if ($shouldSave) { $this->save(); } } } + + private function toUserFriendlyConnectionException(\Throwable $exception): \Throwable + { + $message = str($exception->getMessage())->lower(); + + if ($message->contains(['timed out', 'timeout', 'connection refused', 'could not resolve', 'curl error 28'])) { + return new \RuntimeException( + 'Could not connect to the S3 endpoint within 15 seconds. Please verify the endpoint, bucket, credentials, region, and network/firewall settings.', + previous: $exception, + ); + } + + return $exception; + } } diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php index 51ad46de9..1d5f5f9ce 100644 --- a/app/Models/ScheduledDatabaseBackupExecution.php +++ b/app/Models/ScheduledDatabaseBackupExecution.php @@ -23,6 +23,7 @@ class ScheduledDatabaseBackupExecution extends BaseModel protected function casts(): array { return [ + 'size' => 'integer', 's3_uploaded' => 'boolean', 'local_storage_deleted' => 'boolean', 's3_storage_deleted' => 'boolean', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 784e2c937..b104be642 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ class StandaloneClickhouse extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -44,11 +45,21 @@ class StandaloneClickhouse extends BaseModel 'destination_type', 'destination_id', 'environment_id', + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'clickhouse_admin_password' => 'encrypted', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', @@ -111,6 +122,7 @@ protected function serverStatus(): Attribute public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index e07053c03..2232ec772 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ class StandaloneDragonfly extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -43,11 +44,21 @@ class StandaloneDragonfly extends BaseModel 'destination_type', 'destination_id', 'environment_id', + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'dragonfly_password' => 'encrypted', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', @@ -110,6 +121,7 @@ protected function serverStatus(): Attribute public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 979f45a3d..b9f9f765b 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ class StandaloneKeydb extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -44,11 +45,21 @@ class StandaloneKeydb extends BaseModel 'destination_type', 'destination_id', 'environment_id', + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', ]; protected $appends = ['internal_db_url', 'external_db_url', 'server_status']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'keydb_password' => 'encrypted', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', @@ -111,6 +122,7 @@ protected function serverStatus(): Attribute public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->keydb_conf; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index dba8a52f5..cd94b6c9b 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -12,7 +13,7 @@ class StandaloneMariadb extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -47,11 +48,21 @@ class StandaloneMariadb extends BaseModel 'destination_type', 'destination_id', 'environment_id', + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'mariadb_password' => 'encrypted', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', @@ -114,6 +125,7 @@ protected function serverStatus(): Attribute public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mariadb_conf; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index e72f4f1c6..7d2ffbd74 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ class StandaloneMongodb extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -47,11 +48,21 @@ class StandaloneMongodb extends BaseModel 'destination_type', 'destination_id', 'environment_id', + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', @@ -120,6 +131,7 @@ protected function serverStatus(): Attribute public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mongo_conf; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 1c522d200..f752312d3 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ class StandaloneMysql extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -48,11 +49,21 @@ class StandaloneMysql extends BaseModel 'destination_type', 'destination_id', 'environment_id', + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'mysql_password' => 'encrypted', 'mysql_root_password' => 'encrypted', 'public_port_timeout' => 'integer', @@ -116,6 +127,7 @@ protected function serverStatus(): Attribute public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mysql_conf; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 57dfe5988..04d2291b3 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ class StandalonePostgresql extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -50,11 +51,21 @@ class StandalonePostgresql extends BaseModel 'destination_type', 'destination_id', 'environment_id', + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'init_scripts' => 'array', 'postgres_password' => 'encrypted', 'public_port_timeout' => 'integer', @@ -158,6 +169,7 @@ public function deleteVolumes() public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->postgres_initdb_args.$this->postgres_host_auth_method; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index ef42d7f18..efb0254fb 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ class StandaloneRedis extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -43,11 +44,21 @@ class StandaloneRedis extends BaseModel 'destination_type', 'destination_id', 'environment_id', + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', @@ -115,6 +126,7 @@ protected function serverStatus(): Attribute public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->redis_conf; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/Team.php b/app/Models/Team.php index 744cd6810..23e2badb3 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Actions\User\RevokeUserTeamTokens; use App\Events\ServerReachabilityChanged; use App\Notifications\Channels\SendsDiscord; use App\Notifications\Channels\SendsEmail; @@ -72,6 +73,8 @@ protected static function booted() }); static::deleting(function (Team $team) { + RevokeUserTeamTokens::forTeam($team->id); + foreach ($team->privateKeys as $key) { $key->delete(); } diff --git a/app/Models/User.php b/app/Models/User.php index 01b8902b7..b59b553d9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Actions\User\RevokeUserTeamTokens; use App\Jobs\UpdateStripeCustomerEmailJob; use App\Notifications\Channels\SendsEmail; use App\Notifications\TransactionalEmails\EmailChangeVerification; @@ -98,13 +99,31 @@ protected static function boot() $team['id'] = 0; $team['name'] = 'Root Team'; } + $new_team = $user->id === 0 ? Team::find(0) : null; + + if ($new_team !== null) { + $new_team->forceFill($team); + $new_team->save(); + + if (! $user->teams()->whereKey($new_team->id)->exists()) { + $user->teams()->attach($new_team, ['role' => 'owner']); + } else { + $user->teams()->updateExistingPivot($new_team->id, ['role' => 'owner']); + } + + return; + } + $new_team = (new Team)->forceFill($team); $new_team->save(); + $user->teams()->attach($new_team, ['role' => 'owner']); }); static::deleting(function (User $user) { \DB::transaction(function () use ($user) { + RevokeUserTeamTokens::forUser($user); + $teams = $user->teams; foreach ($teams as $team) { $user_alone_in_team = $team->members->count() === 1; @@ -142,6 +161,7 @@ protected static function boot() if ($found_other_member_who_is_not_owner) { $found_other_member_who_is_not_owner->pivot->role = 'owner'; $found_other_member_who_is_not_owner->pivot->save(); + RevokeUserTeamTokens::forUserTeam($found_other_member_who_is_not_owner, $team->id); $team->members()->detach($user->id); } else { static::finalizeTeamDeletion($user, $team); diff --git a/app/Rules/DockerImageFormat.php b/app/Rules/DockerImageFormat.php index a6a78a76c..038cc2761 100644 --- a/app/Rules/DockerImageFormat.php +++ b/app/Rules/DockerImageFormat.php @@ -2,18 +2,26 @@ namespace App\Rules; +use App\Support\ValidationPatterns; use Closure; use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Translation\PotentiallyTranslatedString; class DockerImageFormat implements ValidationRule { /** * Run the validation rule. * - * @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @param Closure(string, ?string=): PotentiallyTranslatedString $fail */ public function validate(string $attribute, mixed $value, Closure $fail): void { + if (! is_string($value)) { + $fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.'); + + return; + } + // Check if the value contains ":sha256:" or ":sha" which is incorrect format if (preg_match('/:sha256?:/i', $value)) { $fail('The :attribute must use @ before sha256 digest (e.g., image@sha256:hash, not image:sha256:hash).'); @@ -21,20 +29,21 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - // Valid formats: - // 1. image:tag (e.g., nginx:latest) - // 2. registry/image:tag (e.g., ghcr.io/user/app:v1.2.3) - // 3. image@sha256:hash (e.g., nginx@sha256:abc123...) - // 4. registry/image@sha256:hash - // 5. registry:port/image:tag (e.g., localhost:5000/app:latest) + $imageName = $value; + $tag = null; - $pattern = '/^ - (?:[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[0-9]+)?\/)? # Optional registry with optional port - [a-z0-9]+(?:[._\/-][a-z0-9]+)* # Image name (required) - (?::[a-z0-9][a-z0-9._-]*|@sha256:[a-f0-9]{64})? # Optional :tag or @sha256:hash - $/ix'; + if (preg_match('/\A(.+)@sha256:([a-f0-9]{64})\z/i', $value, $matches) === 1) { + $imageName = $matches[1]; + } else { + $lastColon = strrpos($value, ':'); + $lastSlash = strrpos($value, '/'); + if ($lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash)) { + $imageName = substr($value, 0, $lastColon); + $tag = substr($value, $lastColon + 1); + } + } - if (! preg_match($pattern, $value)) { + if (! ValidationPatterns::isValidDockerImageName($imageName) || ! ValidationPatterns::isValidDockerImageTag($tag)) { $fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.'); } } diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index 676b22b6c..8369f9a90 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -306,7 +306,7 @@ private function normalizeValue(mixed $value): mixed private function displayValue(mixed $value): string { if ($value === null) { - return 'Not set'; + return '-'; } if (is_bool($value)) { @@ -323,7 +323,7 @@ private function displayValue(mixed $value): string private function summarizeText(?string $value): string { if (blank($value)) { - return 'Not set'; + return '-'; } $value = trim((string) $value); diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php index 27e8d4c3f..b101b9d5b 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php @@ -37,8 +37,8 @@ public function diff(array $previousSnapshot, array $currentSnapshot): Configura 'impact' => data_get($item, 'impact', 'redeploy'), 'sensitive' => $sensitive, 'display_summary' => $displaySummary, - 'old_display_value' => $sensitive ? ($previous === null ? 'Not set' : 'Set') : data_get($previous, 'display_value', 'Not set'), - 'new_display_value' => $sensitive ? ($current === null ? 'Removed' : 'Set') : data_get($current, 'display_value', 'Not set'), + 'old_display_value' => $sensitive ? ($previous === null ? '-' : '••••••••') : data_get($previous, 'display_value', '-'), + 'new_display_value' => $sensitive ? ($current === null ? '-' : '••••••••') : data_get($current, 'display_value', '-'), ]; } diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 07926e1cf..7e3974dd7 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -102,6 +102,23 @@ class ValidationPatterns */ public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/'; + /** + * Pattern for Docker image repository names without a tag. + * + * Allows an optional registry host/port followed by lowercase repository + * path components. A trailing @sha256 marker is accepted for existing + * digest-based dockerimage records that store the digest hash separately. + */ + public const DOCKER_IMAGE_NAME_PATTERN = '/\A(?=.{1,255}\z)(?:(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]+)?\/)?[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*)(?:@sha256)?\z/'; + + /** + * Pattern for Docker image tags. + * + * Docker tags may contain letters, digits, underscores, dots, and hyphens, + * must start with an alphanumeric/underscore, and are limited to 128 chars. + */ + public const DOCKER_IMAGE_TAG_PATTERN = '/\A[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}\z/'; + /** * Normalize environment variable keys before validation and storage. */ @@ -163,6 +180,81 @@ public static function validatedEnvironmentVariableKey(string $value, string $la return $key; } + /** + * Get validation rules for Docker image repository names without tags. + */ + public static function dockerImageNameRules(bool $required = false, int $maxLength = 255): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "max:$maxLength"; + $rules[] = 'regex:'.self::DOCKER_IMAGE_NAME_PATTERN; + + return $rules; + } + + /** + * Get validation rules for Docker image tags. + */ + public static function dockerImageTagRules(bool $required = false, int $maxLength = 128): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "max:$maxLength"; + $rules[] = 'regex:'.self::DOCKER_IMAGE_TAG_PATTERN; + + return $rules; + } + + /** + * Get validation messages for Docker image fields. + */ + public static function dockerImageMessages(string $nameField = 'docker_registry_image_name', string $tagField = 'docker_registry_image_tag'): array + { + return [ + "{$nameField}.regex" => 'The Docker registry image name must be a valid image repository without a tag and may not contain shell metacharacters.', + "{$tagField}.regex" => 'The Docker registry image tag must be a valid Docker tag and may not contain shell metacharacters.', + ]; + } + + /** + * Check if a string is a valid Docker image repository name without a tag. + */ + public static function isValidDockerImageName(?string $value): bool + { + if (blank($value)) { + return true; + } + + return preg_match(self::DOCKER_IMAGE_NAME_PATTERN, $value) === 1; + } + + /** + * Check if a string is a valid Docker image tag. + */ + public static function isValidDockerImageTag(?string $value): bool + { + if (blank($value)) { + return true; + } + + return preg_match(self::DOCKER_IMAGE_TAG_PATTERN, $value) === 1; + } + /** * Get validation rules for database identifier fields (username, database name). * diff --git a/app/Traits/DeletesUserSessions.php b/app/Traits/DeletesUserSessions.php index e9ec0d946..44ff5f727 100644 --- a/app/Traits/DeletesUserSessions.php +++ b/app/Traits/DeletesUserSessions.php @@ -2,6 +2,7 @@ namespace App\Traits; +use App\Actions\User\RevokeUserTeamTokens; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Session; @@ -17,6 +18,7 @@ public function deleteAllSessions(): void Session::invalidate(); Session::regenerateToken(); DB::table('sessions')->where('user_id', $this->id)->delete(); + RevokeUserTeamTokens::forUser($this->id); } /** diff --git a/app/Traits/HasDatabaseHealthCheck.php b/app/Traits/HasDatabaseHealthCheck.php new file mode 100644 index 000000000..62ca345ed --- /dev/null +++ b/app/Traits/HasDatabaseHealthCheck.php @@ -0,0 +1,45 @@ +health_check_enabled ?? true); + } + + /** + * Build the Docker Compose healthcheck block for the given probe command. + * + * @param array $test The Docker `test` array (e.g. ['CMD', 'pg_isready']). + * @return array + */ + public function healthCheckConfiguration(array $test): array + { + return [ + 'test' => $test, + 'interval' => ($this->health_check_interval ?? 15).'s', + 'timeout' => ($this->health_check_timeout ?? 5).'s', + 'retries' => $this->health_check_retries ?? 5, + 'start_period' => ($this->health_check_start_period ?? 5).'s', + ]; + } + + protected function healthCheckConfigurationHash(): string + { + return implode('|', [ + (int) ($this->health_check_enabled ?? true), + $this->health_check_interval ?? 15, + $this->health_check_timeout ?? 5, + $this->health_check_retries ?? 5, + $this->health_check_start_period ?? 5, + ]); + } +} diff --git a/app/Traits/HasDatabaseStatusInfo.php b/app/Traits/HasDatabaseStatusInfo.php new file mode 100644 index 000000000..5127e0595 --- /dev/null +++ b/app/Traits/HasDatabaseStatusInfo.php @@ -0,0 +1,181 @@ + 'refresh']; + + $user = Auth::user(); + if (! $user) { + return $listeners; + } + + $listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refresh'; + + $team = $user->currentTeam(); + if ($team) { + $listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refresh'; + } + + return $listeners; + } + + public function mount(): void + { + $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; + $this->refresh(); + } + + public function refresh(): void + { + $this->database->refresh(); + if ($this->isPasswordHiddenForMember) { + $this->dbUrl = null; + $this->dbUrlPublic = null; + } else { + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + } + if ($this->supportsSsl()) { + $this->enableSsl = (bool) $this->database->enable_ssl; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + $this->afterRefresh(); + } + } + + /** + * Hook for subclasses with extra status-derived properties (e.g. sslMode). + */ + protected function afterRefresh(): void {} + + public function instantSaveSSL(): void + { + try { + $this->authorize('update', $this->database); + $this->database->enable_ssl = $this->enableSsl; + $this->applyExtraSslAttributes(); + $this->database->save(); + $this->dispatch('success', 'SSL configuration updated.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + /** + * Hook for subclasses with additional SSL columns to persist (e.g. ssl_mode). + */ + protected function applyExtraSslAttributes(): void {} + + public function regenerateSslCertificate(): void + { + try { + $this->authorize('update', $this->database); + + $existingCert = $this->database->sslCertificates()->first(); + + if (! $existingCert) { + $this->dispatch('error', 'No existing SSL certificate found for this database.'); + + return; + } + + $server = $this->database->destination->server; + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); + + if (! $caCert) { + $server->generateCaCertificate(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + + SslHelper::generateSslCertificate( + commonName: $existingCert->common_name, + subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], + resourceType: $existingCert->resource_type, + resourceId: $existingCert->resource_id, + serverId: $existingCert->server_id, + caCert: $caCert->ssl_certificate, + caKey: $caCert->ssl_private_key, + configurationDir: $existingCert->configuration_dir, + mountPath: $existingCert->mount_path, + isPemKeyFileRequired: true, + ); + + $this->refresh(); + $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + public function render(): View + { + return view('livewire.project.database.status-info', [ + 'label' => $this->databaseLabel(), + 'supportsSsl' => $this->supportsSsl(), + 'sslModeOptions' => $this->sslModeOptions(), + 'sslModeHelper' => $this->sslModeHelper(), + 'showPublicUrlPlaceholder' => $this->showPublicUrlPlaceholder(), + 'isExited' => str($this->database->status)->contains('exited'), + 'isPasswordHiddenForMember' => $this->isPasswordHiddenForMember, + ]); + } +} diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 4b76200d2..6a288a064 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -3,15 +3,23 @@ use App\Enums\BuildPackTypes; use App\Enums\RedirectTypes; use App\Enums\StaticImageTypes; +use App\Rules\ValidGitBranch; +use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Request; use Illuminate\Validation\Rule; function getTeamIdFromToken() { - $token = auth()->user()->currentAccessToken(); + $user = auth()->user(); + $token = $user?->currentAccessToken(); + $teamId = data_get($token, 'team_id'); - return data_get($token, 'team_id'); + if (! $user || is_null($teamId) || ! $user->teams()->where('teams.id', $teamId)->exists()) { + return null; + } + + return $teamId; } function invalidTokenResponse() { @@ -83,7 +91,7 @@ function sharedDataApplications() { return [ 'git_repository' => 'string', - 'git_branch' => 'string', + 'git_branch' => ['string', new ValidGitBranch], 'build_pack' => Rule::enum(BuildPackTypes::class), 'is_static' => 'boolean', 'is_spa' => 'boolean', @@ -93,16 +101,16 @@ function sharedDataApplications() 'domains' => 'string|nullable', 'redirect' => Rule::enum(RedirectTypes::class), 'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], - 'docker_registry_image_name' => 'string|nullable', - 'docker_registry_image_tag' => 'string|nullable', - 'install_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), - 'build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), - 'start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), + 'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(), + 'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(), + 'install_command' => ValidationPatterns::shellSafeCommandRules(), + 'build_command' => ValidationPatterns::shellSafeCommandRules(), + 'start_command' => ValidationPatterns::shellSafeCommandRules(), 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/', 'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable', 'custom_network_aliases' => 'string|nullable', - 'base_directory' => \App\Support\ValidationPatterns::directoryPathRules(), - 'publish_directory' => \App\Support\ValidationPatterns::directoryPathRules(), + 'base_directory' => ValidationPatterns::directoryPathRules(), + 'publish_directory' => ValidationPatterns::directoryPathRules(), 'health_check_enabled' => 'boolean', 'health_check_type' => 'string|in:http,cmd', 'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], @@ -125,24 +133,24 @@ function sharedDataApplications() 'limits_cpuset' => 'string|nullable', 'limits_cpu_shares' => 'numeric', 'custom_labels' => 'string|nullable', - 'custom_docker_run_options' => \App\Support\ValidationPatterns::shellSafeCommandRules(2000), + 'custom_docker_run_options' => ValidationPatterns::shellSafeCommandRules(2000), // Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate"). // Access is gated by API token authentication. Commands run inside the app container, not the host. 'post_deployment_command' => 'string|nullable', - 'post_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(), + 'post_deployment_command_container' => ValidationPatterns::containerNameRules(), 'pre_deployment_command' => 'string|nullable', - 'pre_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(), + 'pre_deployment_command_container' => ValidationPatterns::containerNameRules(), 'manual_webhook_secret_github' => 'string|nullable', 'manual_webhook_secret_gitlab' => 'string|nullable', 'manual_webhook_secret_bitbucket' => 'string|nullable', 'manual_webhook_secret_gitea' => 'string|nullable', - 'dockerfile_location' => \App\Support\ValidationPatterns::filePathRules(), - 'dockerfile_target_build' => \App\Support\ValidationPatterns::dockerTargetRules(), - 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), + 'dockerfile_location' => ValidationPatterns::filePathRules(), + 'dockerfile_target_build' => ValidationPatterns::dockerTargetRules(), + 'docker_compose_location' => ValidationPatterns::filePathRules(), 'docker_compose' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', - 'docker_compose_custom_start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), - 'docker_compose_custom_build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), + 'docker_compose_custom_start_command' => ValidationPatterns::shellSafeCommandRules(), + 'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(), 'is_container_label_escape_enabled' => 'boolean', 'is_preserve_repository_enabled' => 'boolean', ]; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 4e59bb39a..08af8ee42 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -353,22 +353,30 @@ function showBoarding(): bool function refreshSession(?Team $team = null): void { if (! $team) { - if (Auth::user()->currentTeam()) { - $team = Team::find(Auth::user()->currentTeam()->id); - } else { - $team = User::find(Auth::id())->teams->first(); + $currentTeam = Auth::user()->currentTeam(); + if ($currentTeam) { + // currentTeam() can resolve a stale (just-deleted) team from the + // session/cache, so Team::find() may still return null here. + $team = Team::find($currentTeam->id); + } + if (! $team) { + // Fall back to any team the user still belongs to. + $team = User::query()->find(Auth::id())?->teams()->first(); } - } - - if (! $team) { - session()->forget('currentTeam'); - Cache::forget('team:'.Auth::id()); - - return; } // Clear old cache key format for backwards compatibility Cache::forget('team:'.Auth::id()); + + if (! $team) { + // The user has no team left (e.g. just deleted their current team and + // belongs to no other): clear the stale session reference instead of + // dereferencing null. + session()->forget('currentTeam'); + + return; + } + // Use new cache key format that includes team ID Cache::forget('user:'.Auth::id().':team:'.$team->id); Cache::remember('user:'.Auth::id().':team:'.$team->id, 3600, function () use ($team) { @@ -600,6 +608,39 @@ function isCloud(): bool return ! config('constants.coolify.self_hosted'); } +/** + * Resolve the queue used for application deployments, database starts and service starts. + * + * On cloud these jobs run on a dedicated `deployments` queue so they can be drained by an + * isolated Horizon worker pool; self-hosted keeps them on the shared `high` queue. Routing + * is decided by `isCloud()` (config-based) rather than `HORIZON_QUEUES`, so the dispatching + * process needs no special env — only the worker must be configured to drain `deployments`. + * + * IMPORTANT: on cloud a worker MUST include `deployments` in its `HORIZON_QUEUES`, otherwise + * these jobs are never processed. + */ +function deployment_queue(): string +{ + return isCloud() ? 'deployments' : 'high'; +} + +/** + * Resolve the queue used for scheduled jobs — the scheduler dispatcher, scheduled tasks and + * scheduled database backups, whether triggered automatically or manually. + * + * On cloud these jobs run on a dedicated `crons` queue so they can be drained by an isolated + * Horizon worker pool; self-hosted keeps them on the shared `high` queue. Routing is decided + * by `isCloud()` (config-based), so the dispatching process needs no special env — only the + * worker must be configured to drain `crons`. + * + * IMPORTANT: on cloud a worker MUST include `crons` in its `HORIZON_QUEUES`, otherwise these + * jobs are never processed. + */ +function crons_queue(): string +{ + return isCloud() ? 'crons' : 'high'; +} + function translate_cron_expression($expression_to_validate): string { if (isset(VALID_CRON_STRINGS[$expression_to_validate])) { diff --git a/composer.lock b/composer.lock index 5947a1588..7d958a9cc 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.374.2", + "version": "3.381.5", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "67b6b6210af47319c74c5666388d71bc1bc58276" + "reference": "409208d62af0ddafbcb0af1a0bf514f5ffcaba92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/67b6b6210af47319c74c5666388d71bc1bc58276", - "reference": "67b6b6210af47319c74c5666388d71bc1bc58276", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/409208d62af0ddafbcb0af1a0bf514f5ffcaba92", + "reference": "409208d62af0ddafbcb0af1a0bf514f5ffcaba92", "shasum": "" }, "require": { @@ -153,22 +153,22 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.374.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.381.5" }, - "time": "2026-03-27T18:05:55+00:00" + "time": "2026-05-20T18:16:01+00:00" }, { "name": "bacon/bacon-qr-code", - "version": "v3.0.4", + "version": "v3.1.1", "source": { "type": "git", "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "3feed0e212b8412cc5d2612706744789b0615824" + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/3feed0e212b8412cc5d2612706744789b0615824", - "reference": "3feed0e212b8412cc5d2612706744789b0615824", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", "shasum": "" }, "require": { @@ -208,9 +208,9 @@ "homepage": "https://github.com/Bacon/BaconQrCode", "support": { "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.4" + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1" }, - "time": "2026-03-16T01:01:30+00:00" + "time": "2026-04-05T21:06:35+00:00" }, { "name": "brick/math", @@ -1035,16 +1035,16 @@ }, { "name": "firebase/php-jwt", - "version": "v7.0.3", + "version": "v7.0.5", "source": { "type": "git", - "url": "https://github.com/firebase/php-jwt.git", - "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e" + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", - "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", "shasum": "" }, "require": { @@ -1052,6 +1052,7 @@ }, "require-dev": { "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", "phpspec/prophecy-phpunit": "^2.0", "phpunit/phpunit": "^9.5", "psr/cache": "^2.0||^3.0", @@ -1091,10 +1092,10 @@ "php" ], "support": { - "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v7.0.3" + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5" }, - "time": "2026-02-25T22:16:40+00:00" + "time": "2026-04-01T20:38:03+00:00" }, { "name": "fruitcake/php-cors", @@ -1231,16 +1232,16 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.0", + "version": "7.10.3", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", + "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", "shasum": "" }, "require": { @@ -1258,8 +1259,9 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.3.2", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -1337,7 +1339,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + "source": "https://github.com/guzzle/guzzle/tree/7.10.3" }, "funding": [ { @@ -1353,20 +1355,20 @@ "type": "tidelift" } ], - "time": "2025-08-23T22:36:01+00:00" + "time": "2026-05-20T22:59:19+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.3.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "481557b130ef3790cf82b713667b43030dc9c957" + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", - "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", "shasum": "" }, "require": { @@ -1374,7 +1376,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "type": "library", "extra": { @@ -1420,7 +1422,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.3.0" + "source": "https://github.com/guzzle/promises/tree/2.4.1" }, "funding": [ { @@ -1436,20 +1438,20 @@ "type": "tidelift" } ], - "time": "2025-08-22T14:34:08+00:00" + "time": "2026-05-20T22:57:30+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.9.0", + "version": "2.10.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" + "reference": "73ab136360b5dfd858006eae9795e8fe43c80361" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/73ab136360b5dfd858006eae9795e8fe43c80361", + "reference": "73ab136360b5dfd858006eae9795e8fe43c80361", "shasum": "" }, "require": { @@ -1464,9 +1466,9 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "0.9.0", + "http-interop/http-factory-tests": "1.1.0", "jshttp/mime-db": "1.54.0.1", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -1537,7 +1539,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.9.0" + "source": "https://github.com/guzzle/psr7/tree/2.10.1" }, "funding": [ { @@ -1553,7 +1555,7 @@ "type": "tidelift" } ], - "time": "2026-03-10T16:41:02+00:00" + "time": "2026-05-20T09:27:36+00:00" }, { "name": "guzzlehttp/uri-template", @@ -1703,28 +1705,29 @@ }, { "name": "laravel/fortify", - "version": "v1.36.2", + "version": "v1.37.2", "source": { "type": "git", "url": "https://github.com/laravel/fortify.git", - "reference": "b36e0782e6f5f6cfbab34327895a63b7c4c031f9" + "reference": "5d4b6a53527edd19ecc4f13e8e74ec91bdefab0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/fortify/zipball/b36e0782e6f5f6cfbab34327895a63b7c4c031f9", - "reference": "b36e0782e6f5f6cfbab34327895a63b7c4c031f9", + "url": "https://api.github.com/repos/laravel/fortify/zipball/5d4b6a53527edd19ecc4f13e8e74ec91bdefab0c", + "reference": "5d4b6a53527edd19ecc4f13e8e74ec91bdefab0c", "shasum": "" }, "require": { "bacon/bacon-qr-code": "^3.0", "ext-json": "*", - "illuminate/console": "^10.0|^11.0|^12.0|^13.0", - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", - "php": "^8.1", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "laravel/passkeys": "^0.2.0", + "php": "^8.2", "pragmarx/google2fa": "^9.0" }, "require-dev": { - "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "orchestra/testbench": "^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -1762,20 +1765,20 @@ "issues": "https://github.com/laravel/fortify/issues", "source": "https://github.com/laravel/fortify" }, - "time": "2026-03-20T20:13:51+00:00" + "time": "2026-05-15T22:59:10+00:00" }, { "name": "laravel/framework", - "version": "v12.55.1", + "version": "v12.60.2", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33" + "reference": "b8b55ce32175cc00f834a56eeb6316f18ed6ea39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/6d9185a248d101b07eecaf8fd60b18129545fd33", - "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33", + "url": "https://api.github.com/repos/laravel/framework/zipball/b8b55ce32175cc00f834a56eeb6316f18ed6ea39", + "reference": "b8b55ce32175cc00f834a56eeb6316f18ed6ea39", "shasum": "" }, "require": { @@ -1816,8 +1819,8 @@ "symfony/mailer": "^7.2.0", "symfony/mime": "^7.2.0", "symfony/polyfill-php83": "^1.33", - "symfony/polyfill-php84": "^1.33", - "symfony/polyfill-php85": "^1.33", + "symfony/polyfill-php84": "^1.34", + "symfony/polyfill-php85": "^1.34", "symfony/process": "^7.2.0", "symfony/routing": "^7.2.0", "symfony/uid": "^7.2.0", @@ -1984,20 +1987,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-18T14:28:59+00:00" + "time": "2026-05-20T11:48:19+00:00" }, { "name": "laravel/horizon", - "version": "v5.45.4", + "version": "v5.47.0", "source": { "type": "git", "url": "https://github.com/laravel/horizon.git", - "reference": "b2b32e3f6013081e0176307e9081cd085f0ad4d6" + "reference": "be74bc494f7a244d74f1c8ad6552f9b8621f10c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/horizon/zipball/b2b32e3f6013081e0176307e9081cd085f0ad4d6", - "reference": "b2b32e3f6013081e0176307e9081cd085f0ad4d6", + "url": "https://api.github.com/repos/laravel/horizon/zipball/be74bc494f7a244d74f1c8ad6552f9b8621f10c6", + "reference": "be74bc494f7a244d74f1c8ad6552f9b8621f10c6", "shasum": "" }, "require": { @@ -2062,9 +2065,9 @@ ], "support": { "issues": "https://github.com/laravel/horizon/issues", - "source": "https://github.com/laravel/horizon/tree/v5.45.4" + "source": "https://github.com/laravel/horizon/tree/v5.47.0" }, - "time": "2026-03-18T14:14:59+00:00" + "time": "2026-05-19T20:54:47+00:00" }, { "name": "laravel/mcp", @@ -2141,16 +2144,16 @@ }, { "name": "laravel/nightwatch", - "version": "v1.24.4", + "version": "v1.27.0", "source": { "type": "git", "url": "https://github.com/laravel/nightwatch.git", - "reference": "127e9bb9928f0fcf69b52b244053b393c90347c8" + "reference": "d0f9cbe7364ffb9e4577c558a8fe7cded4d07a31" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/nightwatch/zipball/127e9bb9928f0fcf69b52b244053b393c90347c8", - "reference": "127e9bb9928f0fcf69b52b244053b393c90347c8", + "url": "https://api.github.com/repos/laravel/nightwatch/zipball/d0f9cbe7364ffb9e4577c558a8fe7cded4d07a31", + "reference": "d0f9cbe7364ffb9e4577c558a8fe7cded4d07a31", "shasum": "" }, "require": { @@ -2179,9 +2182,9 @@ "livewire/livewire": "^2.0|^3.0", "mockery/mockery": "^1.0", "mongodb/laravel-mongodb": "^4.0|^5.0", - "orchestra/testbench": "^8.0|^9.0|^10.0", - "orchestra/testbench-core": "^8.0|^9.0|^10.0", - "orchestra/workbench": "^8.0|^9.0|^10.0", + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "orchestra/testbench-core": "^8.0|^9.0|^10.0|^11.0", + "orchestra/workbench": "^8.0|^9.0|^10.0|^11.0", "phpstan/phpstan": "^1.0", "phpunit/phpunit": "^10.0|^11.0|^12.0", "singlestoredb/singlestoredb-laravel": "^1.0|^2.0", @@ -2231,7 +2234,7 @@ "issues": "https://github.com/laravel/nightwatch/issues", "source": "https://github.com/laravel/nightwatch" }, - "time": "2026-03-18T23:25:05+00:00" + "time": "2026-05-21T01:59:31+00:00" }, { "name": "laravel/pail", @@ -2314,17 +2317,85 @@ "time": "2026-02-09T13:44:54+00:00" }, { - "name": "laravel/prompts", - "version": "v0.3.16", + "name": "laravel/passkeys", + "version": "v0.2.1", "source": { "type": "git", - "url": "https://github.com/laravel/prompts.git", - "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2" + "url": "https://github.com/laravel/passkeys-server.git", + "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/11e7d5f93803a2190b00e145142cb00a33d17ad2", - "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2", + "url": "https://api.github.com/repos/laravel/passkeys-server/zipball/a76656ada41b2b4a591f075eddae5ddc67e8ab9c", + "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/http": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "web-auth/webauthn-lib": "5.3.x" + }, + "require-dev": { + "laravel/pint": "^1.28.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "rector/rector": "^2.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Passkeys\\PasskeysServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Passkeys\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Passwordless authentication using WebAuthn/passkeys for Laravel", + "homepage": "https://github.com/laravel/passkeys-server", + "keywords": [ + "Authentication", + "Passwordless", + "laravel", + "passkeys", + "webauthn" + ], + "support": { + "issues": "https://github.com/laravel/passkeys-server/issues", + "source": "https://github.com/laravel/passkeys-server" + }, + "time": "2026-05-18T16:26:00+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.18", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72", "shasum": "" }, "require": { @@ -2368,22 +2439,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.16" + "source": "https://github.com/laravel/prompts/tree/v0.3.18" }, - "time": "2026-03-23T14:35:33+00:00" + "time": "2026-05-19T00:47:18+00:00" }, { "name": "laravel/sanctum", - "version": "v4.3.1", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", - "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", "shasum": "" }, "require": { @@ -2433,20 +2504,20 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2026-02-07T17:19:31+00:00" + "time": "2026-04-30T11:46:25+00:00" }, { "name": "laravel/sentinel", - "version": "v1.0.1", + "version": "v1.1.0", "source": { "type": "git", "url": "https://github.com/laravel/sentinel.git", - "reference": "7a98db53e0d9d6f61387f3141c07477f97425603" + "reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sentinel/zipball/7a98db53e0d9d6f61387f3141c07477f97425603", - "reference": "7a98db53e0d9d6f61387f3141c07477f97425603", + "url": "https://api.github.com/repos/laravel/sentinel/zipball/972d9885d9d14312a118e9565c4e6ecc5e751ea1", + "reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1", "shasum": "" }, "require": { @@ -2465,9 +2536,6 @@ "providers": [ "Laravel\\Sentinel\\SentinelServiceProvider" ] - }, - "branch-alias": { - "dev-main": "1.x-dev" } }, "autoload": { @@ -2490,22 +2558,22 @@ } ], "support": { - "source": "https://github.com/laravel/sentinel/tree/v1.0.1" + "source": "https://github.com/laravel/sentinel/tree/v1.1.0" }, - "time": "2026-02-12T13:32:54+00:00" + "time": "2026-03-24T14:03:38+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.10", + "version": "v2.0.13", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", "shasum": "" }, "require": { @@ -2553,20 +2621,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-02-20T19:59:49+00:00" + "time": "2026-04-16T14:03:50+00:00" }, { "name": "laravel/socialite", - "version": "v5.26.0", + "version": "v5.27.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "1d26f0c653a5f0e88859f4197830a29fe0cc59d0" + "reference": "40e0757a75637c7b2dff05d3286b0d8fc25e5c0e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/1d26f0c653a5f0e88859f4197830a29fe0cc59d0", - "reference": "1d26f0c653a5f0e88859f4197830a29fe0cc59d0", + "url": "https://api.github.com/repos/laravel/socialite/zipball/40e0757a75637c7b2dff05d3286b0d8fc25e5c0e", + "reference": "40e0757a75637c7b2dff05d3286b0d8fc25e5c0e", "shasum": "" }, "require": { @@ -2625,7 +2693,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-03-24T18:37:47+00:00" + "time": "2026-04-24T14:05:47+00:00" }, { "name": "laravel/tinker", @@ -3020,16 +3088,16 @@ }, { "name": "league/flysystem", - "version": "3.33.0", + "version": "3.34.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "570b8871e0ce693764434b29154c54b434905350" + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", - "reference": "570b8871e0ce693764434b29154c54b434905350", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", "shasum": "" }, "require": { @@ -3097,26 +3165,26 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" }, - "time": "2026-03-25T07:59:30+00:00" + "time": "2026-05-14T10:28:08+00:00" }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.32.0", + "version": "3.34.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0" + "reference": "0c62fdac907791d8649ad3c61cb7a77628344fb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0", - "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/0c62fdac907791d8649ad3c61cb7a77628344fb8", + "reference": "0c62fdac907791d8649ad3c61cb7a77628344fb8", "shasum": "" }, "require": { - "aws/aws-sdk-php": "^3.295.10", + "aws/aws-sdk-php": "^3.371.5", "league/flysystem": "^3.10.0", "league/mime-type-detection": "^1.0.0", "php": "^8.0.2" @@ -3152,9 +3220,9 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.34.0" }, - "time": "2026-02-25T16:46:44+00:00" + "time": "2026-05-04T08:24:00+00:00" }, { "name": "league/flysystem-local", @@ -3570,16 +3638,16 @@ }, { "name": "livewire/livewire", - "version": "v3.7.11", + "version": "v3.8.0", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "addd6e8e9234df75f29e6a327ee2a745a7d67bb6" + "reference": "d81d269243c3f18d302663c0ce5672990df08ca1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/addd6e8e9234df75f29e6a327ee2a745a7d67bb6", - "reference": "addd6e8e9234df75f29e6a327ee2a745a7d67bb6", + "url": "https://api.github.com/repos/livewire/livewire/zipball/d81d269243c3f18d302663c0ce5672990df08ca1", + "reference": "d81d269243c3f18d302663c0ce5672990df08ca1", "shasum": "" }, "require": { @@ -3634,7 +3702,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.7.11" + "source": "https://github.com/livewire/livewire/tree/v3.8.0" }, "funding": [ { @@ -3642,7 +3710,7 @@ "type": "github" } ], - "time": "2026-02-26T00:58:19+00:00" + "time": "2026-04-30T23:56:43+00:00" }, { "name": "log1x/laravel-webfonts", @@ -4025,16 +4093,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.3", + "version": "3.11.4", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "6a7e652845bb018c668220c2a545aded8594fbbf" + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6a7e652845bb018c668220c2a545aded8594fbbf", - "reference": "6a7e652845bb018c668220c2a545aded8594fbbf", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", "shasum": "" }, "require": { @@ -4126,7 +4194,7 @@ "type": "tidelift" } ], - "time": "2026-03-11T17:23:39+00:00" + "time": "2026-04-07T09:57:54+00:00" }, { "name": "nette/schema", @@ -4197,16 +4265,16 @@ }, { "name": "nette/utils", - "version": "v4.1.3", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", "shasum": "" }, "require": { @@ -4282,9 +4350,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.3" + "source": "https://github.com/nette/utils/tree/v4.1.4" }, - "time": "2026-02-13T03:05:33+00:00" + "time": "2026-05-11T20:49:54+00:00" }, { "name": "nikic/php-parser", @@ -4681,102 +4749,6 @@ }, "time": "2020-10-15T08:29:30+00:00" }, - { - "name": "paragonie/sodium_compat", - "version": "v2.5.0", - "source": { - "type": "git", - "url": "https://github.com/paragonie/sodium_compat.git", - "reference": "4714da6efdc782c06690bc72ce34fae7941c2d9f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/4714da6efdc782c06690bc72ce34fae7941c2d9f", - "reference": "4714da6efdc782c06690bc72ce34fae7941c2d9f", - "shasum": "" - }, - "require": { - "php": "^8.1", - "php-64bit": "*" - }, - "require-dev": { - "infection/infection": "^0", - "nikic/php-fuzzer": "^0", - "phpunit/phpunit": "^7|^8|^9|^10|^11", - "vimeo/psalm": "^4|^5|^6" - }, - "suggest": { - "ext-sodium": "Better performance, password hashing (Argon2i), secure memory management (memzero), and better security." - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "files": [ - "autoload.php" - ], - "psr-4": { - "ParagonIE\\Sodium\\": "namespaced/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "ISC" - ], - "authors": [ - { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com" - }, - { - "name": "Frank Denis", - "email": "jedisct1@pureftpd.org" - } - ], - "description": "Pure PHP implementation of libsodium; uses the PHP extension if it exists", - "keywords": [ - "Authentication", - "BLAKE2b", - "ChaCha20", - "ChaCha20-Poly1305", - "Chapoly", - "Curve25519", - "Ed25519", - "EdDSA", - "Edwards-curve Digital Signature Algorithm", - "Elliptic Curve Diffie-Hellman", - "Poly1305", - "Pure-PHP cryptography", - "RFC 7748", - "RFC 8032", - "Salpoly", - "Salsa20", - "X25519", - "XChaCha20-Poly1305", - "XSalsa20-Poly1305", - "Xchacha20", - "Xsalsa20", - "aead", - "cryptography", - "ecdh", - "elliptic curve", - "elliptic curve cryptography", - "encryption", - "libsodium", - "php", - "public-key cryptography", - "secret-key cryptography", - "side-channel resistant" - ], - "support": { - "issues": "https://github.com/paragonie/sodium_compat/issues", - "source": "https://github.com/paragonie/sodium_compat/tree/v2.5.0" - }, - "time": "2025-12-30T16:12:18+00:00" - }, { "name": "php-di/invoker", "version": "2.3.7", @@ -4905,78 +4877,6 @@ ], "time": "2025-08-16T11:10:48+00:00" }, - { - "name": "phpdocumentor/reflection", - "version": "6.4.4", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/Reflection.git", - "reference": "5e5db15b34e6eae755cb97beaa7fe076ae9e8d4c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/Reflection/zipball/5e5db15b34e6eae755cb97beaa7fe076ae9e8d4c", - "reference": "5e5db15b34e6eae755cb97beaa7fe076ae9e8d4c", - "shasum": "" - }, - "require": { - "composer-runtime-api": "^2", - "nikic/php-parser": "~4.18 || ^5.0", - "php": "8.1.*|8.2.*|8.3.*|8.4.*|8.5.*", - "phpdocumentor/reflection-common": "^2.1", - "phpdocumentor/reflection-docblock": "^5", - "phpdocumentor/type-resolver": "^1.4", - "symfony/polyfill-php80": "^1.28", - "webmozart/assert": "^1.7" - }, - "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "doctrine/coding-standard": "^13.0", - "eliashaeussler/phpunit-attributes": "^1.8", - "mikey179/vfsstream": "~1.2", - "mockery/mockery": "~1.6.0", - "phpspec/prophecy-phpunit": "^2.4", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-webmozart-assert": "^1.2", - "phpunit/phpunit": "^10.5.53", - "psalm/phar": "^6.0", - "rector/rector": "^1.0.0", - "squizlabs/php_codesniffer": "^3.8" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-5.x": "5.3.x-dev", - "dev-6.x": "6.0.x-dev" - } - }, - "autoload": { - "files": [ - "src/php-parser/Modifiers.php" - ], - "psr-4": { - "phpDocumentor\\": "src/phpDocumentor" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Reflection library to do Static Analysis for PHP Projects", - "homepage": "http://www.phpdoc.org", - "keywords": [ - "phpDocumentor", - "phpdoc", - "reflection", - "static analysis" - ], - "support": { - "issues": "https://github.com/phpDocumentor/Reflection/issues", - "source": "https://github.com/phpDocumentor/Reflection/tree/6.4.4" - }, - "time": "2025-11-25T21:21:18+00:00" - }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", @@ -5032,16 +4932,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.7", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "31a105931bc8ffa3a123383829772e832fd8d903" + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903", - "reference": "31a105931bc8ffa3a123383829772e832fd8d903", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", "shasum": "" }, "require": { @@ -5049,8 +4949,8 @@ "ext-filter": "*", "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7|^2.0", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { @@ -5060,7 +4960,8 @@ "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26" + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" }, "type": "library", "extra": { @@ -5090,44 +4991,44 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" }, - "time": "2026-03-18T20:47:46+00:00" + "time": "2026-03-18T20:49:53+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.12.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", + "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" + "phpstan/phpdoc-parser": "^2.0" }, "require-dev": { "ext-tokenizer": "*", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" + "psalm/phar": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev" + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -5148,9 +5049,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" }, - "time": "2025-11-21T15:09:14+00:00" + "time": "2026-01-06T21:53:42+00:00" }, { "name": "phpoption/phpoption", @@ -6136,23 +6037,22 @@ }, { "name": "pusher/pusher-php-server", - "version": "7.2.7", + "version": "7.2.8", "source": { "type": "git", "url": "https://github.com/pusher/pusher-http-php.git", - "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7" + "reference": "4aa139ed2a2a805cd265449b691198beee1309d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/148b0b5100d000ed57195acdf548a2b1b38ee3f7", - "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7", + "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/4aa139ed2a2a805cd265449b691198beee1309d2", + "reference": "4aa139ed2a2a805cd265449b691198beee1309d2", "shasum": "" }, "require": { "ext-curl": "*", "ext-json": "*", "guzzlehttp/guzzle": "^7.2", - "paragonie/sodium_compat": "^1.6|^2.0", "php": "^7.3|^8.0", "psr/log": "^1.0|^2.0|^3.0" }, @@ -6191,9 +6091,9 @@ ], "support": { "issues": "https://github.com/pusher/pusher-http-php/issues", - "source": "https://github.com/pusher/pusher-http-php/tree/7.2.7" + "source": "https://github.com/pusher/pusher-http-php/tree/7.2.8" }, - "time": "2025-01-06T10:56:20+00:00" + "time": "2026-05-18T13:11:36+00:00" }, { "name": "ralouphie/getallheaders", @@ -6521,16 +6421,16 @@ }, { "name": "sentry/sentry", - "version": "4.23.0", + "version": "4.27.0", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-php.git", - "reference": "121a674d5fffcdb8e414b75c1b76edba8e592b66" + "reference": "1f0544cff8443ac1d25d6521487118e28381a1c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/121a674d5fffcdb8e414b75c1b76edba8e592b66", - "reference": "121a674d5fffcdb8e414b75c1b76edba8e592b66", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/1f0544cff8443ac1d25d6521487118e28381a1c2", + "reference": "1f0544cff8443ac1d25d6521487118e28381a1c2", "shasum": "" }, "require": { @@ -6547,6 +6447,7 @@ "raven/raven": "*" }, "require-dev": { + "carthage-software/mago": "^1.13.3", "friendsofphp/php-cs-fixer": "^3.4", "guzzlehttp/promises": "^2.0.3", "guzzlehttp/psr7": "^1.8.4|^2.1.1", @@ -6562,6 +6463,7 @@ "spiral/roadrunner-worker": "^3.6" }, "suggest": { + "ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.", "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." }, "type": "library", @@ -6598,7 +6500,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-php/issues", - "source": "https://github.com/getsentry/sentry-php/tree/4.23.0" + "source": "https://github.com/getsentry/sentry-php/tree/4.27.0" }, "funding": [ { @@ -6610,20 +6512,20 @@ "type": "custom" } ], - "time": "2026-03-23T13:15:52+00:00" + "time": "2026-05-06T14:32:16+00:00" }, { "name": "sentry/sentry-laravel", - "version": "4.24.0", + "version": "4.25.1", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-laravel.git", - "reference": "f823bd85e38e06cb4f1b7a82d48a2fc95320b31d" + "reference": "67efbdd74a752fcc1038676986b055a4df7d5084" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/f823bd85e38e06cb4f1b7a82d48a2fc95320b31d", - "reference": "f823bd85e38e06cb4f1b7a82d48a2fc95320b31d", + "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/67efbdd74a752fcc1038676986b055a4df7d5084", + "reference": "67efbdd74a752fcc1038676986b055a4df7d5084", "shasum": "" }, "require": { @@ -6689,7 +6591,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-laravel/issues", - "source": "https://github.com/getsentry/sentry-laravel/tree/4.24.0" + "source": "https://github.com/getsentry/sentry-laravel/tree/4.25.1" }, "funding": [ { @@ -6701,7 +6603,7 @@ "type": "custom" } ], - "time": "2026-03-24T10:33:54+00:00" + "time": "2026-05-05T09:22:46+00:00" }, { "name": "socialiteproviders/authentik", @@ -7337,29 +7239,31 @@ }, { "name": "spatie/laravel-data", - "version": "4.20.1", + "version": "4.23.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-data.git", - "reference": "5490cb15de6fc8b35a8cd2f661fac072d987a1ad" + "reference": "230543769c996e407fec2873930626aed7dd0d3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-data/zipball/5490cb15de6fc8b35a8cd2f661fac072d987a1ad", - "reference": "5490cb15de6fc8b35a8cd2f661fac072d987a1ad", + "url": "https://api.github.com/repos/spatie/laravel-data/zipball/230543769c996e407fec2873930626aed7dd0d3b", + "reference": "230543769c996e407fec2873930626aed7dd0d3b", "shasum": "" }, "require": { "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", "php": "^8.1", - "phpdocumentor/reflection": "^6.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/reflection-docblock": "^5.3 || ^6.0", + "phpdocumentor/type-resolver": "^1.7 || ^2.0", "spatie/laravel-package-tools": "^1.9.0", "spatie/php-structure-discoverer": "^2.0" }, "require-dev": { "fakerphp/faker": "^1.14", "friendsofphp/php-cs-fixer": "^3.0", - "inertiajs/inertia-laravel": "^2.0", + "inertiajs/inertia-laravel": "^2.0|^3.0", "livewire/livewire": "^3.0|^4.0", "mockery/mockery": "^1.6", "nesbot/carbon": "^2.63|^3.0", @@ -7407,7 +7311,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-data/issues", - "source": "https://github.com/spatie/laravel-data/tree/4.20.1" + "source": "https://github.com/spatie/laravel-data/tree/4.23.0" }, "funding": [ { @@ -7415,7 +7319,7 @@ "type": "github" } ], - "time": "2026-03-18T07:44:01+00:00" + "time": "2026-05-08T14:41:13+00:00" }, { "name": "spatie/laravel-markdown", @@ -7495,16 +7399,16 @@ }, { "name": "spatie/laravel-package-tools", - "version": "1.93.0", + "version": "1.93.1", "source": { "type": "git", "url": "https://github.com/spatie/laravel-package-tools.git", - "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7" + "reference": "d5552849801f2642aea710557463234b59ef65eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/0d097bce95b2bf6802fb1d83e1e753b0f5a948e7", - "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb", + "reference": "d5552849801f2642aea710557463234b59ef65eb", "shasum": "" }, "require": { @@ -7544,7 +7448,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-package-tools/issues", - "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.0" + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1" }, "funding": [ { @@ -7552,20 +7456,20 @@ "type": "github" } ], - "time": "2026-02-21T12:49:54+00:00" + "time": "2026-05-19T14:06:37+00:00" }, { "name": "spatie/laravel-ray", - "version": "1.43.7", + "version": "1.43.9", "source": { "type": "git", "url": "https://github.com/spatie/laravel-ray.git", - "reference": "d550d0b5bf87bb1b1668089f3c843e786ee522d3" + "reference": "85137a6ea1d3ecd5ad3adcb43512fff9a5529e72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/d550d0b5bf87bb1b1668089f3c843e786ee522d3", - "reference": "d550d0b5bf87bb1b1668089f3c843e786ee522d3", + "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/85137a6ea1d3ecd5ad3adcb43512fff9a5529e72", + "reference": "85137a6ea1d3ecd5ad3adcb43512fff9a5529e72", "shasum": "" }, "require": { @@ -7584,7 +7488,7 @@ "require-dev": { "guzzlehttp/guzzle": "^7.3", "laravel/framework": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", - "laravel/pint": "^1.27", + "laravel/pint": "^1.29", "orchestra/testbench-core": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "pestphp/pest": "^1.22|^2.0|^3.0|^4.0", "phpstan/phpstan": "^1.10.57|^2.0.2", @@ -7629,7 +7533,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-ray/issues", - "source": "https://github.com/spatie/laravel-ray/tree/1.43.7" + "source": "https://github.com/spatie/laravel-ray/tree/1.43.9" }, "funding": [ { @@ -7641,7 +7545,7 @@ "type": "other" } ], - "time": "2026-03-06T08:19:04+00:00" + "time": "2026-04-28T06:07:04+00:00" }, { "name": "spatie/laravel-schemaless-attributes", @@ -7772,16 +7676,16 @@ }, { "name": "spatie/php-structure-discoverer", - "version": "2.4.0", + "version": "2.4.2", "source": { "type": "git", "url": "https://github.com/spatie/php-structure-discoverer.git", - "reference": "9a53c79b48fca8b6d15faa8cbba47cc430355146" + "reference": "10cd4e0018450d23e2bd8f8472569ad0c445c0fc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/9a53c79b48fca8b6d15faa8cbba47cc430355146", - "reference": "9a53c79b48fca8b6d15faa8cbba47cc430355146", + "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/10cd4e0018450d23e2bd8f8472569ad0c445c0fc", + "reference": "10cd4e0018450d23e2bd8f8472569ad0c445c0fc", "shasum": "" }, "require": { @@ -7839,7 +7743,7 @@ ], "support": { "issues": "https://github.com/spatie/php-structure-discoverer/issues", - "source": "https://github.com/spatie/php-structure-discoverer/tree/2.4.0" + "source": "https://github.com/spatie/php-structure-discoverer/tree/2.4.2" }, "funding": [ { @@ -7847,20 +7751,20 @@ "type": "github" } ], - "time": "2026-02-21T15:57:15+00:00" + "time": "2026-04-28T06:26:02+00:00" }, { "name": "spatie/ray", - "version": "1.47.0", + "version": "1.48.0", "source": { "type": "git", "url": "https://github.com/spatie/ray.git", - "reference": "3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce" + "reference": "974ac9c6e315033ab8ace883d60e094522f88ede" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/ray/zipball/3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce", - "reference": "3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce", + "url": "https://api.github.com/repos/spatie/ray/zipball/974ac9c6e315033ab8ace883d60e094522f88ede", + "reference": "974ac9c6e315033ab8ace883d60e094522f88ede", "shasum": "" }, "require": { @@ -7920,7 +7824,7 @@ ], "support": { "issues": "https://github.com/spatie/ray/issues", - "source": "https://github.com/spatie/ray/tree/1.47.0" + "source": "https://github.com/spatie/ray/tree/1.48.0" }, "funding": [ { @@ -7932,20 +7836,20 @@ "type": "other" } ], - "time": "2026-02-20T20:42:26+00:00" + "time": "2026-03-31T12:44:31+00:00" }, { "name": "spatie/shiki-php", - "version": "2.3.3", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/spatie/shiki-php.git", - "reference": "9d50ff4d9825d87d3283a6695c65ae9c3c3caa6b" + "reference": "b8b0ca32d3a82bc5c533e68ffab96c5d4ec1b9ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/shiki-php/zipball/9d50ff4d9825d87d3283a6695c65ae9c3c3caa6b", - "reference": "9d50ff4d9825d87d3283a6695c65ae9c3c3caa6b", + "url": "https://api.github.com/repos/spatie/shiki-php/zipball/b8b0ca32d3a82bc5c533e68ffab96c5d4ec1b9ba", + "reference": "b8b0ca32d3a82bc5c533e68ffab96c5d4ec1b9ba", "shasum": "" }, "require": { @@ -7989,7 +7893,7 @@ "spatie" ], "support": { - "source": "https://github.com/spatie/shiki-php/tree/2.3.3" + "source": "https://github.com/spatie/shiki-php/tree/2.4.0" }, "funding": [ { @@ -7997,7 +7901,7 @@ "type": "github" } ], - "time": "2026-02-01T09:30:04+00:00" + "time": "2026-04-27T14:27:52+00:00" }, { "name": "spatie/url", @@ -8061,6 +7965,187 @@ ], "time": "2024-03-08T11:35:19+00:00" }, + { + "name": "spomky-labs/cbor-php", + "version": "3.2.3", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/cbor-php.git", + "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32", + "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "ext-mbstring": "*", + "php": ">=8.0" + }, + "require-dev": { + "ext-json": "*", + "roave/security-advisories": "dev-latest", + "symfony/error-handler": "^6.4|^7.1|^8.0", + "symfony/var-dumper": "^6.4|^7.1|^8.0" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags", + "ext-gmp": "GMP or BCMath extensions will drastically improve the library performance" + }, + "type": "library", + "autoload": { + "psr-4": { + "CBOR\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/cbor-php/contributors" + } + ], + "description": "CBOR Encoder/Decoder for PHP", + "keywords": [ + "Concise Binary Object Representation", + "RFC7049", + "cbor" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/cbor-php/issues", + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.3" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-04-01T12:15:20+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8", + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "ext-mbstring": "*", + "php": ">=8.1", + "psr/clock": "^1.0" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31|^0.32", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0|^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-03-23T22:56:56+00:00" + }, { "name": "stevebauman/purify", "version": "v6.3.2", @@ -8188,16 +8273,16 @@ }, { "name": "symfony/clock", - "version": "v8.0.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f" + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "url": "https://api.github.com/repos/symfony/clock/zipball/b55a638b189a6faa875e0ccdb00908fb87af95b3", + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3", "shasum": "" }, "require": { @@ -8241,7 +8326,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.0.0" + "source": "https://github.com/symfony/clock/tree/v8.0.8" }, "funding": [ { @@ -8261,20 +8346,20 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:46:48+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/console", - "version": "v7.4.7", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" + "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "url": "https://api.github.com/repos/symfony/console/zipball/ed0107e43ab452aa77ae99e005b95e56b556e075", + "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075", "shasum": "" }, "require": { @@ -8339,7 +8424,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.7" + "source": "https://github.com/symfony/console/tree/v7.4.11" }, "funding": [ { @@ -8359,20 +8444,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:20+00:00" + "time": "2026-05-13T12:04:42+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.6", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "2a178bf80f05dbbe469a337730eba79d61315262" + "reference": "3665cfade90565430909b906394c73c8739e57d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/2a178bf80f05dbbe469a337730eba79d61315262", - "reference": "2a178bf80f05dbbe469a337730eba79d61315262", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/3665cfade90565430909b906394c73c8739e57d0", + "reference": "3665cfade90565430909b906394c73c8739e57d0", "shasum": "" }, "require": { @@ -8408,7 +8493,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.6" + "source": "https://github.com/symfony/css-selector/tree/v8.0.9" }, "funding": [ { @@ -8428,20 +8513,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T13:07:04+00:00" + "time": "2026-04-18T13:51:42+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -8454,7 +8539,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -8479,7 +8564,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -8490,25 +8575,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/error-handler", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", "shasum": "" }, "require": { @@ -8557,7 +8646,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.4" + "source": "https://github.com/symfony/error-handler/tree/v7.4.8" }, "funding": [ { @@ -8577,20 +8666,20 @@ "type": "tidelift" } ], - "time": "2026-01-20T16:42:42+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.4", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f", "shasum": "" }, "require": { @@ -8642,7 +8731,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9" }, "funding": [ { @@ -8662,20 +8751,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T11:45:55+00:00" + "time": "2026-04-18T13:51:42+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", "shasum": "" }, "require": { @@ -8689,7 +8778,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -8722,7 +8811,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" }, "funding": [ { @@ -8733,25 +8822,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-01-05T13:30:16+00:00" }, { "name": "symfony/filesystem", - "version": "v8.0.6", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "7bf9162d7a0dff98d079b72948508fa48018a770" + "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770", - "reference": "7bf9162d7a0dff98d079b72948508fa48018a770", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/224db910898ce1317b892a9a1338f1f8f17eb7c7", + "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7", "shasum": "" }, "require": { @@ -8788,7 +8881,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.6" + "source": "https://github.com/symfony/filesystem/tree/v8.0.11" }, "funding": [ { @@ -8808,20 +8901,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:59:43+00:00" + "time": "2026-05-11T16:39:47+00:00" }, { "name": "symfony/finder", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" + "reference": "e0be088d22278583a82da281886e8c3592fbf149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", + "reference": "e0be088d22278583a82da281886e8c3592fbf149", "shasum": "" }, "require": { @@ -8856,7 +8949,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.6" + "source": "https://github.com/symfony/finder/tree/v7.4.8" }, "funding": [ { @@ -8876,20 +8969,20 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:40:50+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81" + "reference": "9381209597ec66c25be154cbf2289076e64d1eab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81", - "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab", + "reference": "9381209597ec66c25be154cbf2289076e64d1eab", "shasum": "" }, "require": { @@ -8938,7 +9031,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.7" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.8" }, "funding": [ { @@ -8958,20 +9051,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:15:18+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.7", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1" + "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1", - "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/7922b53e70d2ba2027af8bb6a59d91eb3541ea4d", + "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d", "shasum": "" }, "require": { @@ -9057,7 +9150,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.7" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.12" }, "funding": [ { @@ -9077,20 +9170,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T16:33:18+00:00" + "time": "2026-05-20T09:27:11+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.6", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" + "reference": "5cefb712a25f320579615ba9e1942abaeade7dff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "url": "https://api.github.com/repos/symfony/mailer/zipball/5cefb712a25f320579615ba9e1942abaeade7dff", + "reference": "5cefb712a25f320579615ba9e1942abaeade7dff", "shasum": "" }, "require": { @@ -9141,7 +9234,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.6" + "source": "https://github.com/symfony/mailer/tree/v7.4.12" }, "funding": [ { @@ -9161,20 +9254,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/mime", - "version": "v7.4.7", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1" + "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1", + "url": "https://api.github.com/repos/symfony/mime/zipball/b198dd66c211c97119bcaaff7c13431dbbb5e470", + "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470", "shasum": "" }, "require": { @@ -9230,7 +9323,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.7" + "source": "https://github.com/symfony/mime/tree/v7.4.12" }, "funding": [ { @@ -9250,20 +9343,20 @@ "type": "tidelift" } ], - "time": "2026-03-05T15:24:09+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/options-resolver", - "version": "v8.0.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7" + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7", - "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8", + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8", "shasum": "" }, "require": { @@ -9301,7 +9394,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v8.0.0" + "source": "https://github.com/symfony/options-resolver/tree/v8.0.8" }, "funding": [ { @@ -9321,20 +9414,20 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:55:31+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -9384,7 +9477,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -9404,20 +9497,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-iconv", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-iconv.git", - "reference": "5f3b930437ae03ae5dff61269024d8ea1b3774aa" + "reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/5f3b930437ae03ae5dff61269024d8ea1b3774aa", - "reference": "5f3b930437ae03ae5dff61269024d8ea1b3774aa", + "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/2c5729fd241b4b22f6e4b436bc3354a4f262df57", + "reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57", "shasum": "" }, "require": { @@ -9468,7 +9561,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-iconv/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-iconv/tree/v1.37.0" }, "funding": [ { @@ -9488,20 +9581,20 @@ "type": "tidelift" } ], - "time": "2024-09-17T14:58:18+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", "shasum": "" }, "require": { @@ -9550,7 +9643,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" }, "funding": [ { @@ -9570,20 +9663,20 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-04-26T13:13:48+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { @@ -9637,7 +9730,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { @@ -9657,20 +9750,20 @@ "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -9722,7 +9815,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -9742,20 +9835,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", "shasum": "" }, "require": { @@ -9807,7 +9900,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" }, "funding": [ { @@ -9827,20 +9920,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -9891,7 +9984,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -9911,20 +10004,20 @@ "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", "shasum": "" }, "require": { @@ -9971,7 +10064,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" }, "funding": [ { @@ -9991,20 +10084,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", "shasum": "" }, "require": { @@ -10051,7 +10144,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" }, "funding": [ { @@ -10071,20 +10164,20 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2026-04-10T18:47:49+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", "shasum": "" }, "require": { @@ -10131,7 +10224,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" }, "funding": [ { @@ -10151,20 +10244,20 @@ "type": "tidelift" } ], - "time": "2025-06-23T16:12:55+00:00" + "time": "2026-04-26T13:10:57+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "shasum": "" }, "require": { @@ -10214,7 +10307,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -10234,20 +10327,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/process", - "version": "v7.4.5", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "608476f4604102976d687c483ac63a79ba18cc97" + "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", - "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "url": "https://api.github.com/repos/symfony/process/zipball/d9593c9efa40499eb078b81144de42cbc28a31f0", + "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0", "shasum": "" }, "require": { @@ -10279,7 +10372,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.5" + "source": "https://github.com/symfony/process/tree/v7.4.11" }, "funding": [ { @@ -10299,20 +10392,187 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-05-11T16:55:21+00:00" }, { - "name": "symfony/psr-http-message-bridge", - "version": "v8.0.4", + "name": "symfony/property-access", + "version": "v8.0.8", "source": { "type": "git", - "url": "https://github.com/symfony/psr-http-message-bridge.git", - "reference": "d6edf266746dd0b8e81e754a79da77b08dc00531" + "url": "https://github.com/symfony/property-access.git", + "reference": "704c7808116fcdd67327db7b17de56b8ef6169e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/d6edf266746dd0b8e81e754a79da77b08dc00531", - "reference": "d6edf266746dd0b8e81e754a79da77b08dc00531", + "url": "https://api.github.com/repos/symfony/property-access/zipball/704c7808116fcdd67327db7b17de56b8ef6169e4", + "reference": "704c7808116fcdd67327db7b17de56b8ef6169e4", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/property-info": "^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/property-info", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "c21711980653360d6ef5c26d0f9ca6f58a1135c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/c21711980653360d6ef5c26d0f9ca6f58a1135c6", + "reference": "c21711980653360d6ef5c26d0f9ca6f58a1135c6", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/94facc221260c1d5f20e31ee43cd6c6a824b4a19", + "reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19", "shasum": "" }, "require": { @@ -10366,7 +10626,7 @@ "psr-7" ], "support": { - "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.4" + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.8" }, "funding": [ { @@ -10386,20 +10646,20 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:40:55+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/routing", - "version": "v7.4.6", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" + "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", + "url": "https://api.github.com/repos/symfony/routing/zipball/3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", + "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", "shasum": "" }, "require": { @@ -10451,7 +10711,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.6" + "source": "https://github.com/symfony/routing/tree/v7.4.12" }, "funding": [ { @@ -10471,20 +10731,118 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { - "name": "symfony/service-contracts", - "version": "v3.6.1", + "name": "symfony/serializer", + "version": "v8.0.10", "source": { "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "url": "https://github.com/symfony/serializer.git", + "reference": "72ed7e1475790714f07c3a59bd01fd32cd022fdf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/serializer/zipball/72ed7e1475790714f07c3a59bd01fd32cd022fdf", + "reference": "72ed7e1475790714f07c3a59bd01fd32cd022fdf", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/property-access": "<7.4.2|>=8.0,<8.0.2", + "symfony/property-info": "<7.4", + "symfony/type-info": "<7.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/property-access": "^7.4.2|^8.0.2", + "symfony/property-info": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v8.0.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-04T13:41:39+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -10502,7 +10860,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -10538,7 +10896,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -10558,20 +10916,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "symfony/stopwatch", - "version": "v8.0.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942" + "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/67df1914c6ccd2d7b52f70d40cf2aea02159d942", - "reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/85954ed72d5440ea4dc9a10b7e49e01df766ffa3", + "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3", "shasum": "" }, "require": { @@ -10604,7 +10962,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v8.0.0" + "source": "https://github.com/symfony/stopwatch/tree/v8.0.8" }, "funding": [ { @@ -10624,20 +10982,20 @@ "type": "tidelift" } ], - "time": "2025-08-04T07:36:47+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/string", - "version": "v8.0.6", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", "shasum": "" }, "require": { @@ -10694,7 +11052,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.6" + "source": "https://github.com/symfony/string/tree/v8.0.11" }, "funding": [ { @@ -10714,20 +11072,20 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/translation", - "version": "v8.0.6", + "version": "v8.0.10", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b" + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", + "url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67", "shasum": "" }, "require": { @@ -10787,7 +11145,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.6" + "source": "https://github.com/symfony/translation/tree/v8.0.10" }, "funding": [ { @@ -10807,20 +11165,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T13:07:04+00:00" + "time": "2026-05-06T11:30:54+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", "shasum": "" }, "require": { @@ -10833,7 +11191,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -10869,7 +11227,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" }, "funding": [ { @@ -10889,20 +11247,102 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2026-01-05T13:30:16+00:00" }, { - "name": "symfony/uid", - "version": "v7.4.4", + "name": "symfony/type-info", + "version": "v8.0.9", "source": { "type": "git", - "url": "https://github.com/symfony/uid.git", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + "url": "https://github.com/symfony/type-info.git", + "reference": "08723aceb8c3271e8cb3db8b2565728b0c88e866" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "url": "https://api.github.com/repos/symfony/type-info/zipball/08723aceb8c3271e8cb3db8b2565728b0c88e866", + "reference": "08723aceb8c3271e8cb3db8b2565728b0c88e866", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v8.0.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-29T15:02:55+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "2676b524340abcfe4d6151ec698463cebafee439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", + "reference": "2676b524340abcfe4d6151ec698463cebafee439", "shasum": "" }, "require": { @@ -10947,7 +11387,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.4" + "source": "https://github.com/symfony/uid/tree/v7.4.9" }, "funding": [ { @@ -10967,20 +11407,20 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:30:35+00:00" + "time": "2026-04-30T15:19:22+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", "shasum": "" }, "require": { @@ -11034,7 +11474,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" }, "funding": [ { @@ -11054,20 +11494,20 @@ "type": "tidelift" } ], - "time": "2026-02-15T10:53:20+00:00" + "time": "2026-03-30T13:44:50+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.6", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "58751048de17bae71c5aa0d13cb19d79bca26391" + "reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/58751048de17bae71c5aa0d13cb19d79bca26391", - "reference": "58751048de17bae71c5aa0d13cb19d79bca26391", + "url": "https://api.github.com/repos/symfony/yaml/zipball/8b6952b56ca6417f25f7a65758cadd0ce02edc51", + "reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51", "shasum": "" }, "require": { @@ -11110,7 +11550,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.6" + "source": "https://github.com/symfony/yaml/tree/v7.4.12" }, "funding": [ { @@ -11130,7 +11570,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T09:33:46+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -11343,23 +11783,23 @@ }, { "name": "voku/portable-ascii", - "version": "2.0.3", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/voku/portable-ascii.git", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", "shasum": "" }, "require": { - "php": ">=7.0.0" + "php": ">=7.1.0" }, "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" }, "suggest": { "ext-intl": "Use Intl for transliterator_transliterate() support" @@ -11389,7 +11829,7 @@ ], "support": { "issues": "https://github.com/voku/portable-ascii/issues", - "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" }, "funding": [ { @@ -11413,27 +11853,184 @@ "type": "tidelift" } ], - "time": "2024-11-21T01:49:47+00:00" + "time": "2026-04-26T05:33:54+00:00" }, { - "name": "webmozart/assert", - "version": "1.12.1", + "name": "web-auth/cose-lib", + "version": "4.5.2", "source": { "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + "url": "https://github.com/web-auth/cose-lib.git", + "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5b38660f90070a8e45f3dbc9528ade3b608dd77d", + "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "ext-json": "*", + "ext-openssl": "*", + "php": ">=8.1", + "spomky-labs/pki-framework": "^1.0" + }, + "require-dev": { + "spomky-labs/cbor-php": "^3.2.2" + }, + "suggest": { + "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension", + "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension", + "spomky-labs/cbor-php": "For COSE Signature support" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cose\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/cose/contributors" + } + ], + "description": "CBOR Object Signing and Encryption (COSE) For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "COSE", + "RFC8152" + ], + "support": { + "issues": "https://github.com/web-auth/cose-lib/issues", + "source": "https://github.com/web-auth/cose-lib/tree/4.5.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-05-03T09:49:50+00:00" + }, + { + "name": "web-auth/webauthn-lib", + "version": "5.3.3", + "source": { + "type": "git", + "url": "https://github.com/web-auth/webauthn-lib.git", + "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/e6f656d6c6b29fa305382fe6a0a3be8177d177df", + "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "paragonie/constant_time_encoding": "^2.6|^3.0", + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.3|^6.0", + "psr/clock": "^1.0", + "psr/event-dispatcher": "^1.0", + "psr/log": "^1.0|^2.0|^3.0", + "spomky-labs/cbor-php": "^3.0", + "spomky-labs/pki-framework": "^1.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^3.2", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "web-auth/cose-lib": "^4.2.3" + }, + "suggest": { + "psr/log-implementation": "Recommended to receive logs from the library", + "symfony/event-dispatcher": "Recommended to use dispatched events", + "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/web-auth/webauthn-framework", + "name": "web-auth/webauthn-framework" + } + }, + "autoload": { + "psr-4": { + "Webauthn\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/webauthn-library/contributors" + } + ], + "description": "FIDO2/Webauthn Support For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "FIDO2", + "fido", + "webauthn" + ], + "support": { + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.3" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-05-17T19:04:30+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.2" }, "suggest": { "ext-intl": "", @@ -11442,8 +12039,12 @@ }, "type": "library", "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, "branch-alias": { - "dev-master": "1.10-dev" + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -11459,6 +12060,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -11469,9 +12074,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" + "source": "https://github.com/webmozarts/assert/tree/2.4.0" }, - "time": "2025-10-29T15:56:20+00:00" + "time": "2026-05-20T13:07:01+00:00" }, { "name": "yosymfony/parser-utils", @@ -12199,16 +12804,16 @@ }, { "name": "amphp/hpack", - "version": "v3.2.1", + "version": "v3.2.2", "source": { "type": "git", "url": "https://github.com/amphp/hpack.git", - "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239" + "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/hpack/zipball/4f293064b15682a2b178b1367ddf0b8b5feb0239", - "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239", + "url": "https://api.github.com/repos/amphp/hpack/zipball/291da27078e7e149a9bad4d08ff05bf7d81c89f4", + "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4", "shasum": "" }, "require": { @@ -12217,7 +12822,7 @@ "require-dev": { "amphp/php-cs-fixer-config": "^2", "http2jp/hpack-test-case": "^1", - "nikic/php-fuzzer": "^0.0.10", + "nikic/php-fuzzer": "^0.0.11", "phpunit/phpunit": "^7 | ^8 | ^9" }, "type": "library", @@ -12261,7 +12866,7 @@ ], "support": { "issues": "https://github.com/amphp/hpack/issues", - "source": "https://github.com/amphp/hpack/tree/v3.2.1" + "source": "https://github.com/amphp/hpack/tree/v3.2.2" }, "funding": [ { @@ -12269,7 +12874,7 @@ "type": "github" } ], - "time": "2024-03-21T19:00:16+00:00" + "time": "2026-05-03T19:28:59+00:00" }, { "name": "amphp/http", @@ -12337,16 +12942,16 @@ }, { "name": "amphp/http-client", - "version": "v5.3.4", + "version": "v5.3.6", "source": { "type": "git", "url": "https://github.com/amphp/http-client.git", - "reference": "75ad21574fd632594a2dd914496647816d5106bc" + "reference": "ca155026acafa74a612d776a97202d53077fee86" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/http-client/zipball/75ad21574fd632594a2dd914496647816d5106bc", - "reference": "75ad21574fd632594a2dd914496647816d5106bc", + "url": "https://api.github.com/repos/amphp/http-client/zipball/ca155026acafa74a612d776a97202d53077fee86", + "reference": "ca155026acafa74a612d776a97202d53077fee86", "shasum": "" }, "require": { @@ -12374,9 +12979,8 @@ "amphp/phpunit-util": "^3", "ext-json": "*", "kelunik/link-header-rfc5988": "^1", - "laminas/laminas-diactoros": "^2.3", "phpunit/phpunit": "^9", - "psalm/phar": "~5.23" + "psalm/phar": "6.16.1" }, "suggest": { "amphp/file": "Required for file request bodies and HTTP archive logging", @@ -12423,7 +13027,7 @@ ], "support": { "issues": "https://github.com/amphp/http-client/issues", - "source": "https://github.com/amphp/http-client/tree/v5.3.4" + "source": "https://github.com/amphp/http-client/tree/v5.3.6" }, "funding": [ { @@ -12431,20 +13035,20 @@ "type": "github" } ], - "time": "2025-08-16T20:41:23+00:00" + "time": "2026-05-15T23:29:38+00:00" }, { "name": "amphp/http-server", - "version": "v3.4.4", + "version": "v3.4.5", "source": { "type": "git", "url": "https://github.com/amphp/http-server.git", - "reference": "8dc32cc6a65c12a3543276305796b993c56b76ef" + "reference": "ae0fd01e16aba336247852df0c3f8c649a31896d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/http-server/zipball/8dc32cc6a65c12a3543276305796b993c56b76ef", - "reference": "8dc32cc6a65c12a3543276305796b993c56b76ef", + "url": "https://api.github.com/repos/amphp/http-server/zipball/ae0fd01e16aba336247852df0c3f8c649a31896d", + "reference": "ae0fd01e16aba336247852df0c3f8c649a31896d", "shasum": "" }, "require": { @@ -12471,7 +13075,7 @@ "league/uri-components": "^7.1", "monolog/monolog": "^3", "phpunit/phpunit": "^9", - "psalm/phar": "~5.23" + "psalm/phar": "6.16.1" }, "suggest": { "ext-zlib": "Allows GZip compression of response bodies" @@ -12520,7 +13124,7 @@ ], "support": { "issues": "https://github.com/amphp/http-server/issues", - "source": "https://github.com/amphp/http-server/tree/v3.4.4" + "source": "https://github.com/amphp/http-server/tree/v3.4.5" }, "funding": [ { @@ -12528,7 +13132,7 @@ "type": "github" } ], - "time": "2026-02-08T18:16:29+00:00" + "time": "2026-05-01T03:55:07+00:00" }, { "name": "amphp/parser", @@ -12594,16 +13198,16 @@ }, { "name": "amphp/pipeline", - "version": "v1.2.3", + "version": "v1.2.4", "source": { "type": "git", "url": "https://github.com/amphp/pipeline.git", - "reference": "7b52598c2e9105ebcddf247fc523161581930367" + "reference": "a044733e080940d1483f56caff0c412ad6982776" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367", - "reference": "7b52598c2e9105ebcddf247fc523161581930367", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/a044733e080940d1483f56caff0c412ad6982776", + "reference": "a044733e080940d1483f56caff0c412ad6982776", "shasum": "" }, "require": { @@ -12615,7 +13219,7 @@ "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "phpunit/phpunit": "^9", - "psalm/phar": "^5.18" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -12649,7 +13253,7 @@ ], "support": { "issues": "https://github.com/amphp/pipeline/issues", - "source": "https://github.com/amphp/pipeline/tree/v1.2.3" + "source": "https://github.com/amphp/pipeline/tree/v1.2.4" }, "funding": [ { @@ -12657,7 +13261,7 @@ "type": "github" } ], - "time": "2025-03-16T16:33:53+00:00" + "time": "2026-05-06T05:37:57+00:00" }, { "name": "amphp/process", @@ -12729,24 +13333,27 @@ }, { "name": "amphp/serialization", - "version": "v1.0.0", + "version": "v1.1.0", "source": { "type": "git", "url": "https://github.com/amphp/serialization.git", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1" + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1", + "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.4" }, "require-dev": { - "amphp/php-cs-fixer-config": "dev-master", - "phpunit/phpunit": "^9 || ^8 || ^7" + "amphp/php-cs-fixer-config": "^2", + "ext-json": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -12781,22 +13388,28 @@ ], "support": { "issues": "https://github.com/amphp/serialization/issues", - "source": "https://github.com/amphp/serialization/tree/master" + "source": "https://github.com/amphp/serialization/tree/v1.1.0" }, - "time": "2020-03-25T21:39:07+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-05T15:59:53+00:00" }, { "name": "amphp/socket", - "version": "v2.3.1", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/amphp/socket.git", - "reference": "58e0422221825b79681b72c50c47a930be7bf1e1" + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1", - "reference": "58e0422221825b79681b72c50c47a930be7bf1e1", + "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314", "shasum": "" }, "require": { @@ -12805,17 +13418,17 @@ "amphp/dns": "^2", "ext-openssl": "*", "kelunik/certificate": "^1.1", - "league/uri": "^6.5 | ^7", - "league/uri-interfaces": "^2.3 | ^7", + "league/uri": "^7", + "league/uri-interfaces": "^7", "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" + "revolt/event-loop": "^1" }, "require-dev": { "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "amphp/process": "^2", "phpunit/phpunit": "^9", - "psalm/phar": "5.20" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -12859,7 +13472,7 @@ ], "support": { "issues": "https://github.com/amphp/socket/issues", - "source": "https://github.com/amphp/socket/tree/v2.3.1" + "source": "https://github.com/amphp/socket/tree/v2.4.0" }, "funding": [ { @@ -12867,7 +13480,7 @@ "type": "github" } ], - "time": "2024-04-21T14:33:03+00:00" + "time": "2026-04-19T15:09:56+00:00" }, { "name": "amphp/sync", @@ -13196,16 +13809,16 @@ }, { "name": "brianium/paratest", - "version": "v7.19.2", + "version": "v7.20.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9" + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", - "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", "shasum": "" }, "require": { @@ -13229,7 +13842,7 @@ "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.40", + "phpstan/phpstan": "^2.1.44", "phpstan/phpstan-deprecation-rules": "^2.0.4", "phpstan/phpstan-phpunit": "^2.0.16", "phpstan/phpstan-strict-rules": "^2.0.10", @@ -13273,7 +13886,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.19.2" + "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" }, "funding": [ { @@ -13285,7 +13898,152 @@ "type": "paypal" } ], - "time": "2026-03-09T14:33:17+00:00" + "time": "2026-03-29T15:46:14+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" }, { "name": "daverandom/libdns", @@ -13333,16 +14091,16 @@ }, { "name": "driftingly/rector-laravel", - "version": "2.2.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/driftingly/rector-laravel.git", - "reference": "807840ceb09de6764cbfcce0719108d044a459a9" + "reference": "3c1c13f335b3b4d1a1f944a8ea194020044871ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/807840ceb09de6764cbfcce0719108d044a459a9", - "reference": "807840ceb09de6764cbfcce0719108d044a459a9", + "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/3c1c13f335b3b4d1a1f944a8ea194020044871ed", + "reference": "3c1c13f335b3b4d1a1f944a8ea194020044871ed", "shasum": "" }, "require": { @@ -13363,9 +14121,9 @@ "description": "Rector upgrades rules for Laravel Framework", "support": { "issues": "https://github.com/driftingly/rector-laravel/issues", - "source": "https://github.com/driftingly/rector-laravel/tree/2.2.0" + "source": "https://github.com/driftingly/rector-laravel/tree/2.3.0" }, - "time": "2026-03-19T17:24:38+00:00" + "time": "2026-04-08T10:52:44+00:00" }, { "name": "fakerphp/faker", @@ -13673,16 +14431,16 @@ }, { "name": "laravel/boost", - "version": "v2.4.1", + "version": "v2.4.8", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "f6241df9fd81a86d79a051851177d4ffe3e28506" + "reference": "d11d720cf9537f8d236a11d973e99563a598ec9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/f6241df9fd81a86d79a051851177d4ffe3e28506", - "reference": "f6241df9fd81a86d79a051851177d4ffe3e28506", + "url": "https://api.github.com/repos/laravel/boost/zipball/d11d720cf9537f8d236a11d973e99563a598ec9c", + "reference": "d11d720cf9537f8d236a11d973e99563a598ec9c", "shasum": "" }, "require": { @@ -13691,7 +14449,7 @@ "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", "illuminate/routing": "^11.45.3|^12.41.1|^13.0", "illuminate/support": "^11.45.3|^12.41.1|^13.0", - "laravel/mcp": "^0.5.1|^0.6.0", + "laravel/mcp": "^0.5.1|^0.6.0|~0.7.0,<0.7.1", "laravel/prompts": "^0.3.10", "laravel/roster": "^0.5.0", "php": "^8.2" @@ -13735,20 +14493,20 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-03-25T16:37:40+00:00" + "time": "2026-05-19T20:09:50+00:00" }, { "name": "laravel/dusk", - "version": "v8.5.0", + "version": "v8.6.0", "source": { "type": "git", "url": "https://github.com/laravel/dusk.git", - "reference": "f9f75666bed46d1ebca13792447be6e753f4e790" + "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/dusk/zipball/f9f75666bed46d1ebca13792447be6e753f4e790", - "reference": "f9f75666bed46d1ebca13792447be6e753f4e790", + "url": "https://api.github.com/repos/laravel/dusk/zipball/e7fd48762c6a82ad2cd311db07587aa2a97ce143", + "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143", "shasum": "" }, "require": { @@ -13807,22 +14565,22 @@ ], "support": { "issues": "https://github.com/laravel/dusk/issues", - "source": "https://github.com/laravel/dusk/tree/v8.5.0" + "source": "https://github.com/laravel/dusk/tree/v8.6.0" }, - "time": "2026-03-21T11:50:49+00:00" + "time": "2026-04-15T14:50:40+00:00" }, { "name": "laravel/pint", - "version": "v1.29.0", + "version": "v1.29.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "bdec963f53172c5e36330f3a400604c69bf02d39" + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39", - "reference": "bdec963f53172c5e36330f3a400604c69bf02d39", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", "shasum": "" }, "require": { @@ -13833,14 +14591,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.94.2", - "illuminate/view": "^12.54.1", - "larastan/larastan": "^3.9.3", - "laravel-zero/framework": "^12.0.5", + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.4.0", "pestphp/pest": "^3.8.6", - "shipfastlabs/agent-detector": "^1.1.0" + "shipfastlabs/agent-detector": "^1.1.3" }, "bin": [ "builds/pint" @@ -13877,7 +14635,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-03-12T15:51:39+00:00" + "time": "2026-04-20T15:26:14+00:00" }, { "name": "laravel/roster", @@ -13942,16 +14700,16 @@ }, { "name": "laravel/telescope", - "version": "v5.19.0", + "version": "v5.20.0", "source": { "type": "git", "url": "https://github.com/laravel/telescope.git", - "reference": "5e95df170d14e03dd74c4b744969cf01f67a050b" + "reference": "38ec6e6006a67e05e0c476c5f8ef3550b72e43d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/telescope/zipball/5e95df170d14e03dd74c4b744969cf01f67a050b", - "reference": "5e95df170d14e03dd74c4b744969cf01f67a050b", + "url": "https://api.github.com/repos/laravel/telescope/zipball/38ec6e6006a67e05e0c476c5f8ef3550b72e43d8", + "reference": "38ec6e6006a67e05e0c476c5f8ef3550b72e43d8", "shasum": "" }, "require": { @@ -14005,9 +14763,9 @@ ], "support": { "issues": "https://github.com/laravel/telescope/issues", - "source": "https://github.com/laravel/telescope/tree/v5.19.0" + "source": "https://github.com/laravel/telescope/tree/v5.20.0" }, - "time": "2026-03-24T18:37:14+00:00" + "time": "2026-04-06T12:52:26+00:00" }, { "name": "league/uri-components", @@ -14238,23 +14996,23 @@ }, { "name": "nunomaduro/collision", - "version": "v8.9.1", + "version": "v8.9.4", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", - "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", "shasum": "" }, "require": { "filp/whoops": "^2.18.4", "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.4.4 || ^8.0.4" + "symfony/console": "^7.4.8 || ^8.0.8" }, "conflict": { "laravel/framework": "<11.48.0 || >=14.0.0", @@ -14262,12 +15020,12 @@ }, "require-dev": { "brianium/paratest": "^7.8.5", - "larastan/larastan": "^3.9.2", - "laravel/framework": "^11.48.0 || ^12.52.0", - "laravel/pint": "^1.27.1", - "orchestra/testbench-core": "^9.12.0 || ^10.9.0", - "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", - "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" }, "type": "library", "extra": { @@ -14330,45 +15088,47 @@ "type": "patreon" } ], - "time": "2026-02-17T17:33:08+00:00" + "time": "2026-04-21T14:04:20+00:00" }, { "name": "pestphp/pest", - "version": "v4.4.3", + "version": "v4.7.0", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "e6ab897594312728ef2e32d586cb4f6780b1b495" + "reference": "2fc75cfcf03c041c804778fa894282234adc3c66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/e6ab897594312728ef2e32d586cb4f6780b1b495", - "reference": "e6ab897594312728ef2e32d586cb4f6780b1b495", + "url": "https://api.github.com/repos/pestphp/pest/zipball/2fc75cfcf03c041c804778fa894282234adc3c66", + "reference": "2fc75cfcf03c041c804778fa894282234adc3c66", "shasum": "" }, "require": { - "brianium/paratest": "^7.19.2", - "nunomaduro/collision": "^8.9.1", + "brianium/paratest": "^7.20.0", + "composer/xdebug-handler": "^3.0.5", + "nunomaduro/collision": "^8.9.4", "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", - "pestphp/pest-plugin-arch": "^4.0.0", + "pestphp/pest-plugin-arch": "^4.0.2", "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.14", - "symfony/process": "^7.4.5|^8.0.5" + "phpunit/phpunit": "^12.5.24", + "symfony/process": "^7.4.8|^8.0.8" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.14", + "phpunit/phpunit": ">12.5.24", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { + "mrpunyapal/peststan": "^0.2.9", "pestphp/pest-dev-tools": "^4.1.0", - "pestphp/pest-plugin-browser": "^4.3.0", - "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.21" + "pestphp/pest-plugin-browser": "^4.3.1", + "pestphp/pest-plugin-type-coverage": "^4.0.4", + "psy/psysh": "^0.12.22" }, "bin": [ "bin/pest" @@ -14395,6 +15155,7 @@ "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", "Pest\\Plugins\\Shard", + "Pest\\Plugins\\Tia", "Pest\\Plugins\\Parallel" ] }, @@ -14434,7 +15195,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.4.3" + "source": "https://github.com/pestphp/pest/tree/v4.7.0" }, "funding": [ { @@ -14446,7 +15207,7 @@ "type": "github" } ], - "time": "2026-03-21T13:14:39+00:00" + "time": "2026-05-03T16:09:32+00:00" }, { "name": "pestphp/pest-plugin", @@ -14520,26 +15281,26 @@ }, { "name": "pestphp/pest-plugin-arch", - "version": "v4.0.0", + "version": "v4.0.2", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-arch.git", - "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d" + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/25bb17e37920ccc35cbbcda3b00d596aadf3e58d", - "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d", + "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", "shasum": "" }, "require": { "pestphp/pest-plugin": "^4.0.0", "php": "^8.3", - "ta-tikoma/phpunit-architecture-test": "^0.8.5" + "ta-tikoma/phpunit-architecture-test": "^0.8.7" }, "require-dev": { - "pestphp/pest": "^4.0.0", - "pestphp/pest-dev-tools": "^4.0.0" + "pestphp/pest": "^4.4.6", + "pestphp/pest-dev-tools": "^4.1.0" }, "type": "library", "extra": { @@ -14574,7 +15335,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.0" + "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.2" }, "funding": [ { @@ -14586,20 +15347,20 @@ "type": "github" } ], - "time": "2025-08-20T13:10:51+00:00" + "time": "2026-04-10T17:20:19+00:00" }, { "name": "pestphp/pest-plugin-browser", - "version": "v4.3.0", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-browser.git", - "reference": "48bc408033281974952a6b296592cef3b920a2db" + "reference": "b6e76d3e4a2f81da9f050ec54be2a29b402287c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/48bc408033281974952a6b296592cef3b920a2db", - "reference": "48bc408033281974952a6b296592cef3b920a2db", + "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/b6e76d3e4a2f81da9f050ec54be2a29b402287c4", + "reference": "b6e76d3e4a2f81da9f050ec54be2a29b402287c4", "shasum": "" }, "require": { @@ -14607,20 +15368,20 @@ "amphp/http-server": "^3.4.4", "amphp/websocket-client": "^2.0.2", "ext-sockets": "*", - "pestphp/pest": "^4.3.2", + "pestphp/pest": "^4.4.5", "pestphp/pest-plugin": "^4.0.0", "php": "^8.3", - "symfony/process": "^7.4.5|^8.0.5" + "symfony/process": "^7.4.8|^8.0.5" }, "require-dev": { "ext-pcntl": "*", "ext-posix": "*", - "livewire/livewire": "^3.7.10", - "nunomaduro/collision": "^8.9.0", - "orchestra/testbench": "^10.9.0", + "livewire/livewire": "^3.7.15", + "nunomaduro/collision": "^8.9.3", + "orchestra/testbench": "^10.11.0", "pestphp/pest-dev-tools": "^4.1.0", - "pestphp/pest-plugin-laravel": "^4.0", - "pestphp/pest-plugin-type-coverage": "^4.0.3" + "pestphp/pest-plugin-laravel": "^4.1", + "pestphp/pest-plugin-type-coverage": "^4.0.4" }, "type": "library", "extra": { @@ -14653,7 +15414,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.3.0" + "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.3.1" }, "funding": [ { @@ -14669,7 +15430,7 @@ "type": "patreon" } ], - "time": "2026-02-17T14:54:40+00:00" + "time": "2026-04-08T21:04:12+00:00" }, { "name": "pestphp/pest-plugin-mutate", @@ -15063,11 +15824,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.44", + "version": "2.1.55", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218", - "reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9eaac3826ed5e9b8427350a43cac825eeca3f566", + "reference": "9eaac3826ed5e9b8427350a43cac825eeca3f566", "shasum": "" }, "require": { @@ -15112,20 +15873,20 @@ "type": "github" } ], - "time": "2026-03-25T17:34:21+00:00" + "time": "2026-05-18T11:57:34+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.3", + "version": "12.5.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" + "reference": "876099a072646c7745f673d7aeab5382c4439691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", + "reference": "876099a072646c7745f673d7aeab5382c4439691", "shasum": "" }, "require": { @@ -15134,7 +15895,6 @@ "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", "php": ">=8.3", - "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", "sebastian/environment": "^8.0.3", @@ -15181,7 +15941,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6" }, "funding": [ { @@ -15201,7 +15961,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T06:01:44+00:00" + "time": "2026-04-15T08:23:17+00:00" }, { "name": "phpunit/php-file-iterator", @@ -15462,16 +16222,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.14", + "version": "12.5.24", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" + "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d75dd30597caa80e72fad2ef7904601a30ef1046", + "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046", "shasum": "" }, "require": { @@ -15485,15 +16245,15 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-code-coverage": "^12.5.6", "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.4", + "sebastian/comparator": "^7.1.6", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", + "sebastian/environment": "^8.1.0", "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", @@ -15540,49 +16300,33 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.24" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:38:40+00:00" + "time": "2026-05-01T04:21:04+00:00" }, { "name": "rector/rector", - "version": "2.3.9", + "version": "2.4.4", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "917842143fd9f5331a2adefc214b8d7143bd32c4" + "reference": "4661c582a20f03df585d2e3fdc4af1b83d67a091" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/917842143fd9f5331a2adefc214b8d7143bd32c4", - "reference": "917842143fd9f5331a2adefc214b8d7143bd32c4", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/4661c582a20f03df585d2e3fdc4af1b83d67a091", + "reference": "4661c582a20f03df585d2e3fdc4af1b83d67a091", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.40" + "phpstan/phpstan": "^2.1.48" }, "conflict": { "rector/rector-doctrine": "*", @@ -15616,7 +16360,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.9" + "source": "https://github.com/rectorphp/rector/tree/2.4.4" }, "funding": [ { @@ -15624,20 +16368,20 @@ "type": "github" } ], - "time": "2026-03-16T09:43:55+00:00" + "time": "2026-05-20T19:30:21+00:00" }, { "name": "revolt/event-loop", - "version": "v1.0.8", + "version": "v1.0.9", "source": { "type": "git", "url": "https://github.com/revoltphp/event-loop.git", - "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c" + "reference": "44061cf513e53c6200372fc935ac42271566295d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/b6fc06dce8e9b523c9946138fa5e62181934f91c", - "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d", + "reference": "44061cf513e53c6200372fc935ac42271566295d", "shasum": "" }, "require": { @@ -15647,7 +16391,7 @@ "ext-json": "*", "jetbrains/phpstorm-stubs": "^2019.3", "phpunit/phpunit": "^9", - "psalm/phar": "^5.15" + "psalm/phar": "6.16.*" }, "type": "library", "extra": { @@ -15694,29 +16438,29 @@ ], "support": { "issues": "https://github.com/revoltphp/event-loop/issues", - "source": "https://github.com/revoltphp/event-loop/tree/v1.0.8" + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9" }, - "time": "2025-08-27T21:33:23+00:00" + "time": "2026-05-16T17:55:38+00:00" }, { "name": "sebastian/cli-parser", - "version": "4.2.0", + "version": "4.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -15745,7 +16489,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" }, "funding": [ { @@ -15765,20 +16509,20 @@ "type": "tidelift" } ], - "time": "2025-09-14T09:36:45+00:00" + "time": "2026-05-17T05:29:34+00:00" }, { "name": "sebastian/comparator", - "version": "7.1.4", + "version": "7.1.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + "reference": "7c65c1e79836812819705b473a90c12399542485" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", "shasum": "" }, "require": { @@ -15786,10 +16530,10 @@ "ext-mbstring": "*", "php": ">=8.3", "sebastian/diff": "^7.0", - "sebastian/exporter": "^7.0" + "sebastian/exporter": "^7.0.3" }, "require-dev": { - "phpunit/phpunit": "^12.2" + "phpunit/phpunit": "^12.5.25" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -15837,7 +16581,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" }, "funding": [ { @@ -15857,7 +16601,7 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:28:48+00:00" + "time": "2026-05-21T04:45:25+00:00" }, { "name": "sebastian/complexity", @@ -15986,16 +16730,16 @@ }, { "name": "sebastian/environment", - "version": "8.0.4", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11" + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", - "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6", + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6", "shasum": "" }, "require": { @@ -16010,7 +16754,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -16038,7 +16782,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.4" + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0" }, "funding": [ { @@ -16058,29 +16802,29 @@ "type": "tidelift" } ], - "time": "2026-03-15T07:05:40+00:00" + "time": "2026-04-15T12:13:01+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.2", + "version": "7.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7" + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", "shasum": "" }, "require": { "ext-mbstring": "*", "php": ">=8.3", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -16128,7 +16872,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" }, "funding": [ { @@ -16148,7 +16892,7 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:16:11+00:00" + "time": "2026-05-20T04:37:17+00:00" }, { "name": "sebastian/global-state", @@ -16226,24 +16970,24 @@ }, { "name": "sebastian/lines-of-code", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -16272,15 +17016,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2025-02-07T04:57:28+00:00" + "time": "2026-05-19T16:22:07+00:00" }, { "name": "sebastian/object-enumerator", @@ -16474,23 +17230,23 @@ }, { "name": "sebastian/type", - "version": "6.0.3", + "version": "6.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" + "reference": "82ff822c2edc46724be9f7411d3163021f602773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -16519,7 +17275,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" }, "funding": [ { @@ -16539,7 +17295,7 @@ "type": "tidelift" } ], - "time": "2025-08-09T06:57:12+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { "name": "sebastian/version", @@ -16597,20 +17353,21 @@ }, { "name": "serversideup/spin", - "version": "v3.1.1", + "version": "v3.2.3", "source": { "type": "git", "url": "https://github.com/serversideup/spin.git", - "reference": "5da5b5485b03e4f75d501b93b8a7e8ab973157cd" + "reference": "764b09fdfe83249117abfd913af4103b75edc586" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/serversideup/spin/zipball/5da5b5485b03e4f75d501b93b8a7e8ab973157cd", - "reference": "5da5b5485b03e4f75d501b93b8a7e8ab973157cd", + "url": "https://api.github.com/repos/serversideup/spin/zipball/764b09fdfe83249117abfd913af4103b75edc586", + "reference": "764b09fdfe83249117abfd913af4103b75edc586", "shasum": "" }, "bin": [ - "bin/spin" + "bin/spin", + "bin/spin-mcp-wait.sh" ], "type": "library", "notification-url": "https://packagist.org/downloads/", @@ -16630,7 +17387,7 @@ "description": "Replicate your production environment locally using Docker. Just run \"spin up\". It's really that easy.", "support": { "issues": "https://github.com/serversideup/spin/issues", - "source": "https://github.com/serversideup/spin/tree/v3.1.1" + "source": "https://github.com/serversideup/spin/tree/v3.2.3" }, "funding": [ { @@ -16638,7 +17395,7 @@ "type": "github" } ], - "time": "2025-11-06T19:13:57+00:00" + "time": "2026-04-16T21:33:58+00:00" }, { "name": "spatie/error-solutions", @@ -16716,16 +17473,16 @@ }, { "name": "spatie/flare-client-php", - "version": "1.11.0", + "version": "1.11.1", "source": { "type": "git", "url": "https://github.com/spatie/flare-client-php.git", - "reference": "fb3ffb946675dba811fbde9122224db2f84daca9" + "reference": "53f41b08a27cc039e1a8ed2be9a202e924f31bad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/fb3ffb946675dba811fbde9122224db2f84daca9", - "reference": "fb3ffb946675dba811fbde9122224db2f84daca9", + "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/53f41b08a27cc039e1a8ed2be9a202e924f31bad", + "reference": "53f41b08a27cc039e1a8ed2be9a202e924f31bad", "shasum": "" }, "require": { @@ -16773,7 +17530,7 @@ ], "support": { "issues": "https://github.com/spatie/flare-client-php/issues", - "source": "https://github.com/spatie/flare-client-php/tree/1.11.0" + "source": "https://github.com/spatie/flare-client-php/tree/1.11.1" }, "funding": [ { @@ -16781,7 +17538,7 @@ "type": "github" } ], - "time": "2026-03-17T08:06:16+00:00" + "time": "2026-05-15T09:31:32+00:00" }, { "name": "spatie/ignition", @@ -17015,16 +17772,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.7", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af" + "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af", + "url": "https://api.github.com/repos/symfony/http-client/zipball/7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6", + "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6", "shasum": "" }, "require": { @@ -17092,7 +17849,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.7" + "source": "https://github.com/symfony/http-client/tree/v7.4.9" }, "funding": [ { @@ -17112,20 +17869,20 @@ "type": "tidelift" } ], - "time": "2026-03-05T11:16:58+00:00" + "time": "2026-04-29T13:25:15+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "75d7043853a42837e68111812f4d964b01e5101c" + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", - "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", "shasum": "" }, "require": { @@ -17138,7 +17895,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -17174,7 +17931,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" }, "funding": [ { @@ -17185,12 +17942,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-29T11:18:49+00:00" + "time": "2026-03-06T13:17:50+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", @@ -17311,5 +18072,5 @@ "php": "^8.4" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/constants.php b/config/constants.php index bd3e5b2aa..fd66a682a 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,9 +2,9 @@ return [ 'coolify' => [ - 'version' => '4.1.0', + 'version' => '4.1.2', 'helper_version' => '1.0.14', - 'realtime_version' => '1.0.15', + 'realtime_version' => '1.0.16', 'railpack_version' => '0.23.0', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), @@ -16,7 +16,7 @@ 'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'), 'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'), 'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'), - 'releases_url' => 'https://cdn.coolify.io/releases.json', + 'releases_url' => env('RELEASES_URL', 'https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json'), ], 'urls' => [ @@ -35,6 +35,7 @@ 'protocol' => env('TERMINAL_PROTOCOL'), 'host' => env('TERMINAL_HOST'), 'port' => env('TERMINAL_PORT'), + 'command_timeout' => 0, ], 'pusher' => [ @@ -67,9 +68,6 @@ 'ssh' => [ 'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true)), 'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', 3600), - 'mux_health_check_enabled' => env('SSH_MUX_HEALTH_CHECK_ENABLED', true), - 'mux_health_check_timeout' => env('SSH_MUX_HEALTH_CHECK_TIMEOUT', 5), - 'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes 'connection_timeout' => 10, 'server_interval' => 20, 'command_timeout' => 3600, @@ -94,6 +92,23 @@ 'sentry_dsn' => env('SENTRY_DSN'), ], + 'sentinel' => [ + // How often (seconds) PushServerUpdateJob is force-dispatched even when + // the container state hash is unchanged. Keeps exited-detection and + // storage checks from going stale without writing every resource row on + // every push. + 'push_force_interval_seconds' => env('SENTINEL_PUSH_FORCE_INTERVAL_SECONDS', 300), + + ], + + 'proxy' => [ + // How often (seconds) PushServerUpdateJob periodically re-connects the + // proxy to Docker networks as a safety net. Real network-layout changes + // already connect the proxy on-demand; this only covers gaps (Swarm + // networks added via UI, proxy crash recovery). + 'connect_networks_interval_seconds' => env('PROXY_CONNECT_NETWORKS_INTERVAL_SECONDS', 3600), + ], + 'webhooks' => [ 'feedback_discord_webhook' => env('FEEDBACK_DISCORD_WEBHOOK'), 'dev_webhook' => env('SERVEO_URL'), diff --git a/config/database.php b/config/database.php index a5e0ba703..9238a7055 100644 --- a/config/database.php +++ b/config/database.php @@ -1,6 +1,64 @@ 'pgsql', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_HOST', 'coolify-db'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'coolify'), + 'username' => env('DB_USERNAME', 'coolify'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + 'options' => [ + (defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? Pgsql::ATTR_DISABLE_PREPARES : PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false), + ], +]; + +/* + * Opt-in read/write replica split. Activates only when DB_READ_HOST is set. + * When unset, the pgsql connection is identical to a single-primary setup. + * Hosts may be comma-separated; Laravel random-picks one per connection. + */ +if (env('DB_READ_HOST')) { + $pgsql['read'] = [ + 'host' => $parseDatabaseHosts(env('DB_READ_HOST'), env('DB_HOST', 'coolify-db')), + 'port' => env('DB_READ_PORT', env('DB_PORT', '5432')), + 'username' => env('DB_READ_USERNAME', env('DB_USERNAME', 'coolify')), + 'password' => env('DB_READ_PASSWORD', env('DB_PASSWORD', '')), + ]; + $pgsql['write'] = [ + 'host' => $parseDatabaseHosts(env('DB_WRITE_HOST'), env('DB_HOST', 'coolify-db')), + 'port' => env('DB_WRITE_PORT', env('DB_PORT', '5432')), + 'username' => env('DB_WRITE_USERNAME', env('DB_USERNAME', 'coolify')), + 'password' => env('DB_WRITE_PASSWORD', env('DB_PASSWORD', '')), + ]; + $pgsql['sticky'] = (bool) env('DB_STICKY', true); +} return [ @@ -35,23 +93,7 @@ 'connections' => [ - 'pgsql' => [ - 'driver' => 'pgsql', - 'url' => env('DATABASE_URL'), - 'host' => env('DB_HOST', 'coolify-db'), - 'port' => env('DB_PORT', '5432'), - 'database' => env('DB_DATABASE', 'coolify'), - 'username' => env('DB_USERNAME', 'coolify'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => 'utf8', - 'prefix' => '', - 'prefix_indexes' => true, - 'search_path' => 'public', - 'sslmode' => 'prefer', - 'options' => [ - (defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? \Pdo\Pgsql::ATTR_DISABLE_PREPARES : \PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false), - ], - ], + 'pgsql' => $pgsql, 'testing' => [ 'driver' => 'sqlite', diff --git a/database/migrations/2026_05_27_000000_add_push_server_update_job_indexes.php b/database/migrations/2026_05_27_000000_add_push_server_update_job_indexes.php new file mode 100644 index 000000000..e74929147 --- /dev/null +++ b/database/migrations/2026_05_27_000000_add_push_server_update_job_indexes.php @@ -0,0 +1,27 @@ +getDriverName() !== 'pgsql') { + return; + } + + // Fillfactor < 100 leaves free space per page so Postgres can do HOT + // (Heap-Only Tuple) in-place updates instead of allocating a new tuple + // elsewhere. Coolify's hot-update tables churn rows on every Sentinel + // push / status change; without page-local headroom, non-HOT updates + // accumulate dead tuples and bloat the heap (we've seen up to 50× on + // cloud). Lower fillfactor on hot-update tables, default on the rest. + DB::statement('ALTER TABLE applications SET (fillfactor = 70)'); + DB::statement('ALTER TABLE servers SET (fillfactor = 85)'); + DB::statement('ALTER TABLE services SET (fillfactor = 85)'); + DB::statement('ALTER TABLE service_applications SET (fillfactor = 85)'); + DB::statement('ALTER TABLE service_databases SET (fillfactor = 85)'); + DB::statement('ALTER TABLE standalone_postgresqls SET (fillfactor = 85)'); + DB::statement('ALTER TABLE standalone_redis SET (fillfactor = 85)'); + DB::statement('ALTER TABLE standalone_mongodbs SET (fillfactor = 85)'); + DB::statement('ALTER TABLE standalone_mysqls SET (fillfactor = 85)'); + DB::statement('ALTER TABLE standalone_mariadbs SET (fillfactor = 85)'); + DB::statement('ALTER TABLE standalone_keydbs SET (fillfactor = 85)'); + DB::statement('ALTER TABLE standalone_dragonflies SET (fillfactor = 85)'); + DB::statement('ALTER TABLE standalone_clickhouses SET (fillfactor = 85)'); + DB::statement('ALTER TABLE application_deployment_queues SET (fillfactor = 90)'); + + // Autovacuum default kicks in at 20% dead tuples — too lazy for our + // churn rate. Trigger at 5% on the highest-write tables to keep heap + // pages tidy and prevent visibility-map gaps that hurt scan plans. + DB::statement('ALTER TABLE applications SET (autovacuum_vacuum_scale_factor = 0.05)'); + DB::statement('ALTER TABLE servers SET (autovacuum_vacuum_scale_factor = 0.05)'); + DB::statement('ALTER TABLE service_applications SET (autovacuum_vacuum_scale_factor = 0.05)'); + DB::statement('ALTER TABLE service_databases SET (autovacuum_vacuum_scale_factor = 0.05)'); + DB::statement('ALTER TABLE standalone_postgresqls SET (autovacuum_vacuum_scale_factor = 0.05)'); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + + DB::statement('ALTER TABLE applications RESET (fillfactor, autovacuum_vacuum_scale_factor)'); + DB::statement('ALTER TABLE servers RESET (fillfactor, autovacuum_vacuum_scale_factor)'); + DB::statement('ALTER TABLE services RESET (fillfactor)'); + DB::statement('ALTER TABLE service_applications RESET (fillfactor, autovacuum_vacuum_scale_factor)'); + DB::statement('ALTER TABLE service_databases RESET (fillfactor, autovacuum_vacuum_scale_factor)'); + DB::statement('ALTER TABLE standalone_postgresqls RESET (fillfactor, autovacuum_vacuum_scale_factor)'); + DB::statement('ALTER TABLE standalone_redis RESET (fillfactor)'); + DB::statement('ALTER TABLE standalone_mongodbs RESET (fillfactor)'); + DB::statement('ALTER TABLE standalone_mysqls RESET (fillfactor)'); + DB::statement('ALTER TABLE standalone_mariadbs RESET (fillfactor)'); + DB::statement('ALTER TABLE standalone_keydbs RESET (fillfactor)'); + DB::statement('ALTER TABLE standalone_dragonflies RESET (fillfactor)'); + DB::statement('ALTER TABLE standalone_clickhouses RESET (fillfactor)'); + DB::statement('ALTER TABLE application_deployment_queues RESET (fillfactor)'); + } +}; diff --git a/database/migrations/2026_05_31_000000_add_health_check_to_standalone_databases.php b/database/migrations/2026_05_31_000000_add_health_check_to_standalone_databases.php new file mode 100644 index 000000000..63d7c3497 --- /dev/null +++ b/database/migrations/2026_05_31_000000_add_health_check_to_standalone_databases.php @@ -0,0 +1,47 @@ +tables as $table) { + Schema::table($table, function (Blueprint $table) { + $table->boolean('health_check_enabled')->default(true); + $table->integer('health_check_interval')->default(15); + $table->integer('health_check_timeout')->default(5); + $table->integer('health_check_retries')->default(5); + $table->integer('health_check_start_period')->default(5); + }); + } + } + + public function down(): void + { + foreach ($this->tables as $table) { + Schema::table($table, function (Blueprint $table) { + $table->dropColumn([ + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', + ]); + }); + } + } +}; diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index 212bcce79..2a0273e0f 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -47,22 +47,6 @@ public function run(): void 'source_id' => 1, 'source_type' => GithubApp::class, ]); - Application::create([ - 'uuid' => 'railpack-nodejs', - 'name' => 'Railpack NodeJS Fastify Example', - 'fqdn' => 'http://railpack-nodejs.127.0.0.1.sslip.io', - 'repository_project_id' => 603035348, - 'git_repository' => 'coollabsio/coolify-examples', - 'git_branch' => 'v4.x', - 'base_directory' => '/nodejs', - 'build_pack' => 'railpack', - 'ports_exposes' => '3000', - 'environment_id' => 1, - 'destination_id' => 0, - 'destination_type' => StandaloneDocker::class, - 'source_id' => 1, - 'source_type' => GithubApp::class, - ]); Application::create([ 'uuid' => 'dockerfile', 'name' => 'Dockerfile Example', @@ -161,21 +145,5 @@ public function run(): void 'source_id' => 1, 'source_type' => GitlabApp::class, ]); - Application::create([ - 'uuid' => 'railpack-static', - 'name' => 'Railpack Static Example', - 'fqdn' => 'http://railpack-static.127.0.0.1.sslip.io', - 'repository_project_id' => 603035348, - 'git_repository' => 'coollabsio/coolify-examples', - 'git_branch' => 'v4.x', - 'base_directory' => '/static', - 'build_pack' => 'railpack', - 'ports_exposes' => '80', - 'environment_id' => 1, - 'destination_id' => 0, - 'destination_type' => StandaloneDocker::class, - 'source_id' => 1, - 'source_type' => GithubApp::class, - ]); } } diff --git a/database/seeders/ApplicationSettingsSeeder.php b/database/seeders/ApplicationSettingsSeeder.php index e8be0ba70..87236df8a 100644 --- a/database/seeders/ApplicationSettingsSeeder.php +++ b/database/seeders/ApplicationSettingsSeeder.php @@ -22,12 +22,5 @@ public function run(): void $gitlabPublic->settings->is_static = true; $gitlabPublic->settings->save(); } - - $railpackStatic = Application::where('uuid', 'railpack-static')->first(); - if ($railpackStatic) { - $railpackStatic->load(['settings']); - $railpackStatic->settings->is_static = true; - $railpackStatic->settings->save(); - } } } diff --git a/database/seeders/ProductionSeeder.php b/database/seeders/ProductionSeeder.php index 511af1a9f..4d492a297 100644 --- a/database/seeders/ProductionSeeder.php +++ b/database/seeders/ProductionSeeder.php @@ -32,6 +32,16 @@ public function run(): void echo " Running in self-hosted mode.\n"; } + if (Team::find(0) === null) { + (new Team)->forceFill([ + 'id' => 0, + 'name' => 'Root Team', + 'description' => 'The root team', + 'personal_team' => true, + 'show_boarding' => true, + ])->save(); + } + if (User::find(0) !== null && Team::find(0) !== null) { if (DB::table('team_user')->where('user_id', 0)->first() === null) { DB::table('team_user')->insert([ diff --git a/database/seeders/RootUserSeeder.php b/database/seeders/RootUserSeeder.php index c4e93af63..9bc93a9a9 100644 --- a/database/seeders/RootUserSeeder.php +++ b/database/seeders/RootUserSeeder.php @@ -3,6 +3,7 @@ namespace Database\Seeders; use App\Models\InstanceSettings; +use App\Models\Team; use App\Models\User; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Hash; @@ -52,6 +53,12 @@ public function run(): void 'password' => Hash::make(env('ROOT_USER_PASSWORD')), ]); $user->save(); + + $team = Team::find(0); + if ($team !== null && ! $user->teams()->where('team_id', 0)->exists()) { + $user->teams()->attach($team, ['role' => 'owner']); + } + echo "\n SUCCESS Root user created successfully.\n\n"; } catch (\Exception $e) { echo "\n ERROR Failed to create root user: {$e->getMessage()}\n\n"; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 50edc140f..9c93678af 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -129,7 +129,7 @@ services: networks: - coolify minio: - image: ghcr.io/coollabsio/maxio:latest + image: coollabsio/maxio:latest pull_policy: always container_name: coolify-minio ports: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 56c5b416b..8907a30b9 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.16' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index e1c09c64c..da045fe03 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -96,7 +96,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.16' pull_policy: always container_name: coolify-realtime restart: always diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json index 5c6fa94aa..cdb29bffa 100644 --- a/docker/coolify-realtime/package-lock.json +++ b/docker/coolify-realtime/package-lock.json @@ -10,7 +10,7 @@ "cookie": "1.1.1", "dotenv": "17.3.1", "node-pty": "1.1.0", - "ws": "8.19.0" + "ws": "8.20.1" } }, "node_modules/@xterm/addon-fit": { @@ -70,9 +70,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/docker/coolify-realtime/package.json b/docker/coolify-realtime/package.json index 25bf786a8..9128c0c3f 100644 --- a/docker/coolify-realtime/package.json +++ b/docker/coolify-realtime/package.json @@ -7,6 +7,6 @@ "cookie": "1.1.1", "dotenv": "17.3.1", "node-pty": "1.1.0", - "ws": "8.19.0" + "ws": "8.20.1" } } diff --git a/docker/coolify-realtime/soketi-entrypoint.sh b/docker/coolify-realtime/soketi-entrypoint.sh index 3bb85bdeb..7197e4a0c 100644 --- a/docker/coolify-realtime/soketi-entrypoint.sh +++ b/docker/coolify-realtime/soketi-entrypoint.sh @@ -1,35 +1,91 @@ #!/bin/sh -# Function to timestamp logs -# Check if the first argument is 'watch' if [ "$1" = "watch" ]; then WATCH_MODE="--watch" else WATCH_MODE="" fi -timestamp() { - date "+%Y-%m-%d %H:%M:%S" +log() { + echo "$(date '+%Y-%m-%d %H:%M:%S') [ENTRYPOINT] $*" } -# Start the terminal server in the background with logging -node $WATCH_MODE /terminal/terminal-server.js > >(while read line; do echo "$(timestamp) [TERMINAL] $line"; done) 2>&1 & +start_logger() { + prefix="$1" + fifo_path="$2" + + while read -r line; do + echo "$(date '+%Y-%m-%d %H:%M:%S') [$prefix] $line" + done < "$fifo_path" & +} + +cleanup() { + rm -f "$TERMINAL_LOG_FIFO" "$SOKETI_LOG_FIFO" +} + +TERMINAL_LOG_FIFO="/tmp/coolify-terminal-log.$$" +SOKETI_LOG_FIFO="/tmp/coolify-soketi-log.$$" + +rm -f "$TERMINAL_LOG_FIFO" "$SOKETI_LOG_FIFO" +mkfifo "$TERMINAL_LOG_FIFO" "$SOKETI_LOG_FIFO" + +trap cleanup EXIT + +log "Starting realtime container" +log "WATCH_MODE=${WATCH_MODE:-off}" +log "SOKETI_DEBUG=${SOKETI_DEBUG:-unset}" +log "NODE_OPTIONS=${NODE_OPTIONS:-unset}" + +start_logger "TERMINAL" "$TERMINAL_LOG_FIFO" +TERMINAL_LOGGER_PID=$! + +start_logger "SOKETI" "$SOKETI_LOG_FIFO" +SOKETI_LOGGER_PID=$! + +node $WATCH_MODE /terminal/terminal-server.js > "$TERMINAL_LOG_FIFO" 2>&1 & TERMINAL_PID=$! -# Start the Soketi process in the background with logging -node /app/bin/server.js start > >(while read line; do echo "$(timestamp) [SOKETI] $line"; done) 2>&1 & +log "Terminal server started pid=$TERMINAL_PID logger_pid=$TERMINAL_LOGGER_PID" + +node /app/bin/server.js start > "$SOKETI_LOG_FIFO" 2>&1 & SOKETI_PID=$! -# Function to forward signals to child processes +log "Soketi started pid=$SOKETI_PID logger_pid=$SOKETI_LOGGER_PID" + forward_signal() { - kill -$1 $TERMINAL_PID $SOKETI_PID + log "Forwarding signal $1 to terminal=$TERMINAL_PID soketi=$SOKETI_PID" + + kill -"$1" "$TERMINAL_PID" 2>/dev/null || true + kill -"$1" "$SOKETI_PID" 2>/dev/null || true } -# Forward SIGTERM to child processes trap 'forward_signal TERM' TERM +trap 'forward_signal INT' INT -# Wait for any process to exit -wait -n +while true; do + if ! kill -0 "$TERMINAL_PID" 2>/dev/null; then + wait "$TERMINAL_PID" + EXIT_CODE=$? -# Exit with status of process that exited first -exit $? + log "Terminal server exited code=$EXIT_CODE; stopping soketi pid=$SOKETI_PID" + + kill "$SOKETI_PID" 2>/dev/null || true + wait "$SOKETI_PID" 2>/dev/null || true + + exit "$EXIT_CODE" + fi + + if ! kill -0 "$SOKETI_PID" 2>/dev/null; then + wait "$SOKETI_PID" + EXIT_CODE=$? + + log "Soketi exited code=$EXIT_CODE; stopping terminal pid=$TERMINAL_PID" + + kill "$TERMINAL_PID" 2>/dev/null || true + wait "$TERMINAL_PID" 2>/dev/null || true + + exit "$EXIT_CODE" + fi + + sleep 1 +done diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 42ca7c81d..519792716 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -8,6 +8,7 @@ import { extractSshArgs, extractTargetHost, extractTimeout, + getTerminalSessionTimeout, isAuthorizedTargetHost, } from './terminal-utils.js'; @@ -63,9 +64,11 @@ function createHttpError(response) { } const userSessions = new Map(); -const terminalDebugEnabled = ['1', 'true', 'yes'].includes( - String(process.env.TERMINAL_DEBUG || '').toLowerCase() -); +const envName = String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase(); +const debugOverride = String(process.env.TERMINAL_DEBUG || '').toLowerCase(); +const terminalDebugEnabled = + ['local', 'development'].includes(envName) + || ['1', 'true', 'yes', 'on'].includes(debugOverride); function logTerminal(level, message, context = {}) { if (!terminalDebugEnabled) { @@ -154,7 +157,6 @@ const verifyClient = async (info, callback) => { const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient }); const HEARTBEAT_INTERVAL_MS = 30000; -const IDLE_TIMEOUT_MS = 30 * 60 * 1000; wss.on('connection', async (ws, req) => { ws.isAlive = true; @@ -168,9 +170,9 @@ wss.on('connection', async (ws, req) => { ptyProcess: null, isActive: false, authorizedIPs: [], - lastActivityAt: Date.now(), authReady: false, pendingMessages: [], + terminalSessionTimer: null, }; const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); const connectionContext = { @@ -260,29 +262,6 @@ const heartbeat = setInterval(() => { } catch (_) { // ignore — close handler will follow } - - const session = ws.userId ? userSessions.get(ws.userId) : null; - if (session?.isActive && session.lastActivityAt && (Date.now() - session.lastActivityAt > IDLE_TIMEOUT_MS)) { - const idleMs = Date.now() - session.lastActivityAt; - logTerminal('warn', 'Closing terminal session due to idle timeout.', { - userId: ws.userId, - idleMs, - idleTimeoutMs: IDLE_TIMEOUT_MS, - }); - try { - ws.send('idle-timeout'); - } catch (_) { - // ignore — close still attempted below - } - killPtyProcess(ws.userId); - setTimeout(() => { - try { - ws.close(1000, 'Idle timeout'); - } catch (_) { - // ignore — already closed - } - }, 100); - } }); }, HEARTBEAT_INTERVAL_MS); @@ -290,11 +269,9 @@ wss.on('close', () => clearInterval(heartbeat)); const messageHandlers = { message: (session, data) => { - session.lastActivityAt = Date.now(); session.ptyProcess.write(data); }, resize: (session, { cols, rows }) => { - session.lastActivityAt = Date.now(); cols = cols > 0 ? cols : 80; rows = rows > 0 ? rows : 30; session.ptyProcess.resize(cols, rows) @@ -365,8 +342,14 @@ async function handleCommand(ws, command, userId) { } } + if (userSession.terminalSessionTimer) { + clearTimeout(userSession.terminalSessionTimer); + userSession.terminalSessionTimer = null; + } + const commandString = command[0].split('\n').join(' '); - const timeout = extractTimeout(commandString); + const commandTimeout = extractTimeout(commandString); + const terminalSessionTimeout = getTerminalSessionTimeout(); const sshArgs = extractSshArgs(commandString); const hereDocContent = extractHereDocContent(commandString); @@ -375,7 +358,8 @@ async function handleCommand(ws, command, userId) { logTerminal('log', 'Parsed terminal command metadata.', { userId, targetHost, - timeout, + commandTimeout, + terminalSessionTimeout, sshArgs, authorizedIPs: userSession?.authorizedIPs ?? [], }); @@ -414,13 +398,13 @@ async function handleCommand(ws, command, userId) { logTerminal('log', 'Spawning PTY process for terminal session.', { userId, targetHost, - timeout, + commandTimeout, + terminalSessionTimeout, }); const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options); userSession.ptyProcess = ptyProcess; userSession.isActive = true; - userSession.lastActivityAt = Date.now(); ws.send('pty-ready'); @@ -437,13 +421,16 @@ async function handleCommand(ws, command, userId) { }); ws.send('pty-exited'); userSession.isActive = false; + + if (userSession.terminalSessionTimer) { + clearTimeout(userSession.terminalSessionTimer); + userSession.terminalSessionTimer = null; + } }); - if (timeout) { - setTimeout(async () => { - await killPtyProcess(userId); - }, timeout * 1000); - } + userSession.terminalSessionTimer = setTimeout(async () => { + await killPtyProcess(userId); + }, terminalSessionTimeout * 1000); } async function handleError(err, userId) { @@ -485,6 +472,11 @@ async function killPtyProcess(userId) { setTimeout(() => { if (!session.isActive || !session.ptyProcess) { + if (session.terminalSessionTimer) { + clearTimeout(session.terminalSessionTimer); + session.terminalSessionTimer = null; + } + logTerminal('log', 'PTY process terminated successfully.', { userId, killAttempts, diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js index 7456b282c..8769d62d9 100644 --- a/docker/coolify-realtime/terminal-utils.js +++ b/docker/coolify-realtime/terminal-utils.js @@ -1,3 +1,9 @@ +export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60; + +export function getTerminalSessionTimeout() { + return MAX_TERMINAL_SESSION_TIMEOUT_SECONDS; +} + export function extractTimeout(commandString) { const timeoutMatch = commandString.match(/timeout (\d+)/); return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null; diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js index 3da444155..bf863099b 100644 --- a/docker/coolify-realtime/terminal-utils.test.js +++ b/docker/coolify-realtime/terminal-utils.test.js @@ -1,8 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + MAX_TERMINAL_SESSION_TIMEOUT_SECONDS, extractSshArgs, extractTargetHost, + getTerminalSessionTimeout, isAuthorizedTargetHost, normalizeHostForAuthorization, } from './terminal-utils.js'; @@ -45,3 +47,10 @@ test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => { test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => { assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false); }); + + +test('getTerminalSessionTimeout always enforces the maximum terminal session lifetime', () => { + assert.equal(getTerminalSessionTimeout(null), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS); + assert.equal(getTerminalSessionTimeout(60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS); + assert.equal(getTerminalSessionTimeout(MAX_TERMINAL_SESSION_TIMEOUT_SECONDS + 60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS); +}); diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index dc9a06c1e..8fc46e32d 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -8,6 +8,8 @@ ARG CLOUDFLARED_VERSION=2025.7.0 # https://www.postgresql.org/support/versioning/ # Note: We are using version 18 of the postgres client (while still using postgres 15 for the postgres server) as version 15 has been removed from Alpine 3.23+ https://pkgs.alpinelinux.org/packages?name=postgresql*-client&branch=v3.23&repo=&arch=x86_64&origin=&flagged=&maintainer= ARG POSTGRES_VERSION=18 +# https://nginx.org/en/linux_packages.html +ARG NGINX_VERSION=1.31.0-r1 # ================================================================= # Get MinIO client @@ -24,11 +26,24 @@ ARG GROUP_ID ARG TARGETPLATFORM ARG POSTGRES_VERSION ARG CLOUDFLARED_VERSION +ARG NGINX_VERSION WORKDIR /var/www/html USER root +# Install patched Nginx from the official nginx.org Alpine repository +RUN set -eux; \ + apk add --no-cache ca-certificates curl; \ + NGINX_ALPINE_VERSION="$(egrep -o '^[0-9]+\.[0-9]+' /etc/alpine-release)"; \ + NGINX_REPOSITORY="https://nginx.org/packages/mainline/alpine/v${NGINX_ALPINE_VERSION}/main"; \ + sed -i 's|http://nginx.org/packages|https://nginx.org/packages|g' /etc/apk/repositories; \ + grep -qxF "@nginx ${NGINX_REPOSITORY}" /etc/apk/repositories || echo "@nginx ${NGINX_REPOSITORY}" >> /etc/apk/repositories; \ + curl -fsSL https://nginx.org/keys/nginx_signing.rsa.pub -o /etc/apk/keys/nginx_signing.rsa.pub; \ + apk add --no-cache --upgrade "nginx@nginx=${NGINX_VERSION}"; \ + rm -f /etc/nginx/nginx.conf /etc/nginx/conf.d/default.conf; \ + nginx -v + RUN docker-php-serversideup-set-id www-data $USER_ID:$GROUP_ID && \ docker-php-serversideup-set-file-permissions --owner $USER_ID:$GROUP_ID --service nginx @@ -38,7 +53,6 @@ RUN apk upgrade --no-cache && \ mkdir -p /usr/share/keyrings && \ curl -fSsL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /usr/share/keyrings/postgresql.gpg -RUN sed -i 's|http://nginx.org/packages|https://nginx.org/packages|g' /etc/apk/repositories # Install system dependencies RUN apk add --no-cache \ diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index a01dd595c..0f849785e 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -8,6 +8,8 @@ ARG CLOUDFLARED_VERSION=2025.7.0 # https://www.postgresql.org/support/versioning/ # Note: We are using version 18 of the postgres client (while still using postgres 15 for the postgres server) as version 15 has been removed from Alpine 3.23+ https://pkgs.alpinelinux.org/packages?name=postgresql*-client&branch=v3.23&repo=&arch=x86_64&origin=&flagged=&maintainer= ARG POSTGRES_VERSION=18 +# https://nginx.org/en/linux_packages.html +ARG NGINX_VERSION=1.31.0-r1 # Add user/group ARG USER_ID=9999 @@ -20,6 +22,19 @@ FROM serversideup/php:${SERVERSIDEUP_PHP_VERSION} AS base USER root +# Install patched Nginx from the official nginx.org Alpine repository +ARG NGINX_VERSION +RUN set -eux; \ + apk add --no-cache ca-certificates curl; \ + NGINX_ALPINE_VERSION="$(egrep -o '^[0-9]+\.[0-9]+' /etc/alpine-release)"; \ + NGINX_REPOSITORY="https://nginx.org/packages/mainline/alpine/v${NGINX_ALPINE_VERSION}/main"; \ + sed -i 's|http://nginx.org/packages|https://nginx.org/packages|g' /etc/apk/repositories; \ + grep -qxF "@nginx ${NGINX_REPOSITORY}" /etc/apk/repositories || echo "@nginx ${NGINX_REPOSITORY}" >> /etc/apk/repositories; \ + curl -fsSL https://nginx.org/keys/nginx_signing.rsa.pub -o /etc/apk/keys/nginx_signing.rsa.pub; \ + apk add --no-cache --upgrade "nginx@nginx=${NGINX_VERSION}"; \ + rm -f /etc/nginx/nginx.conf /etc/nginx/conf.d/default.conf; \ + nginx -v + ARG USER_ID ARG GROUP_ID @@ -60,12 +75,25 @@ ARG GROUP_ID ARG TARGETPLATFORM ARG POSTGRES_VERSION ARG CLOUDFLARED_VERSION +ARG NGINX_VERSION ARG CI=true WORKDIR /var/www/html USER root +# Install patched Nginx from the official nginx.org Alpine repository +RUN set -eux; \ + apk add --no-cache ca-certificates curl; \ + NGINX_ALPINE_VERSION="$(egrep -o '^[0-9]+\.[0-9]+' /etc/alpine-release)"; \ + NGINX_REPOSITORY="https://nginx.org/packages/mainline/alpine/v${NGINX_ALPINE_VERSION}/main"; \ + sed -i 's|http://nginx.org/packages|https://nginx.org/packages|g' /etc/apk/repositories; \ + grep -qxF "@nginx ${NGINX_REPOSITORY}" /etc/apk/repositories || echo "@nginx ${NGINX_REPOSITORY}" >> /etc/apk/repositories; \ + curl -fsSL https://nginx.org/keys/nginx_signing.rsa.pub -o /etc/apk/keys/nginx_signing.rsa.pub; \ + apk add --no-cache --upgrade "nginx@nginx=${NGINX_VERSION}"; \ + rm -f /etc/nginx/nginx.conf /etc/nginx/conf.d/default.conf; \ + nginx -v + RUN docker-php-serversideup-set-id www-data $USER_ID:$GROUP_ID && \ docker-php-serversideup-set-file-permissions --owner $USER_ID:$GROUP_ID --service nginx diff --git a/openapi.json b/openapi.json index e83538f2b..f3bda9ab8 100644 --- a/openapi.json +++ b/openapi.json @@ -4605,6 +4605,35 @@ "mysql_conf": { "type": "string", "description": "MySQL conf" + }, + "health_check_enabled": { + "type": "boolean", + "description": "Enable the database healthcheck probe.", + "default": true + }, + "health_check_interval": { + "type": "integer", + "description": "Healthcheck interval in seconds.", + "minimum": 1, + "default": 15 + }, + "health_check_timeout": { + "type": "integer", + "description": "Healthcheck timeout in seconds.", + "minimum": 1, + "default": 5 + }, + "health_check_retries": { + "type": "integer", + "description": "Healthcheck retries count.", + "minimum": 1, + "default": 5 + }, + "health_check_start_period": { + "type": "integer", + "description": "Healthcheck start period in seconds.", + "minimum": 0, + "default": 5 } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index 523d453ff..fae8d7110 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2950,6 +2950,30 @@ paths: mysql_conf: type: string description: 'MySQL conf' + health_check_enabled: + type: boolean + description: 'Enable the database healthcheck probe.' + default: true + health_check_interval: + type: integer + description: 'Healthcheck interval in seconds.' + minimum: 1 + default: 15 + health_check_timeout: + type: integer + description: 'Healthcheck timeout in seconds.' + minimum: 1 + default: 5 + health_check_retries: + type: integer + description: 'Healthcheck retries count.' + minimum: 1 + default: 5 + health_check_start_period: + type: integer + description: 'Healthcheck start period in seconds.' + minimum: 0 + default: 5 type: object responses: '200': diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 3a9bfd501..8907a30b9 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.15' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.16' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index cc72d487b..da045fe03 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -96,7 +96,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.15' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.16' pull_policy: always container_name: coolify-realtime restart: always diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 368e1e379..9c9a405aa 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,16 +1,16 @@ { "coolify": { "v4": { - "version": "4.1.0" + "version": "4.1.2" }, "nightly": { - "version": "4.0.0" + "version": "4.2.0" }, "helper": { - "version": "1.0.13" + "version": "1.0.14" }, "realtime": { - "version": "1.0.15" + "version": "1.0.16" }, "sentinel": { "version": "0.0.21" diff --git a/package-lock.json b/package-lock.json index dcca9c394..9d495c412 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,20 +10,15 @@ "@tailwindcss/typography": "0.5.16", "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", - "ioredis": "5.6.1", "playwright": "^1.58.2" }, "devDependencies": { "@tailwindcss/postcss": "4.1.18", - "@vitejs/plugin-vue": "6.0.3", - "laravel-echo": "2.2.7", "laravel-vite-plugin": "2.0.1", - "postcss": "8.5.6", - "pusher-js": "8.4.0", + "postcss": "8.5.15", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.18", - "vite": "7.3.2", - "vue": "3.5.26" + "vite": "7.3.2" } }, "node_modules/@alloc/quick-lru": { @@ -39,56 +34,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -531,12 +476,6 @@ "node": ">=18" } }, - "node_modules/@ioredis/commands": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", - "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", - "license": "MIT" - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -587,13 +526,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -944,14 +876,6 @@ "win32" ] }, - "node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@tailwindcss/forms": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", @@ -1324,132 +1248,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@vitejs/plugin-vue": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", - "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.53" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", - "vue": "^3.2.25" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", - "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/shared": "3.5.26", - "entities": "^7.0.0", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", - "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.26", - "@vue/shared": "3.5.26" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", - "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/compiler-core": "3.5.26", - "@vue/compiler-dom": "3.5.26", - "@vue/compiler-ssr": "3.5.26", - "@vue/shared": "3.5.26", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.6", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", - "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.26", - "@vue/shared": "3.5.26" - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", - "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.26" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz", - "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.26", - "@vue/shared": "3.5.26" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", - "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.26", - "@vue/runtime-core": "3.5.26", - "@vue/shared": "3.5.26", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz", - "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.26", - "@vue/shared": "3.5.26" - }, - "peerDependencies": { - "vue": "3.5.26" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", - "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", - "dev": true, - "license": "MIT" - }, "node_modules/@xterm/addon-fit": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", @@ -1475,15 +1273,6 @@ "node": ">=6" } }, - "node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1496,39 +1285,6 @@ "node": ">=4" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1539,32 +1295,6 @@ "node": ">=8" } }, - "node_modules/engine.io-client": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", - "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.18.3", - "xmlhttprequest-ssl": "~2.1.1" - } - }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/enhanced-resolve": { "version": "5.19.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", @@ -1579,19 +1309,6 @@ "node": ">=10.13.0" } }, - "node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -1634,13 +1351,6 @@ "@esbuild/win32-x64": "0.27.2" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1681,30 +1391,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ioredis": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz", - "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==", - "license": "MIT", - "dependencies": { - "@ioredis/commands": "^1.1.1", - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.4", - "denque": "^2.1.0", - "lodash.defaults": "^4.2.0", - "lodash.isarguments": "^3.1.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" - }, - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -1715,20 +1401,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/laravel-echo": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.2.7.tgz", - "integrity": "sha512-MgD3ZFXqH5OOVdRjxNHPyQ0ijRr5+nLr7MtyF2XP+kRfhl+Qaa7qVzbtCn1HMgXuTn4SWH6ivn4qWVLlvRl8kg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "pusher-js": "*", - "socket.io-client": "*" - } - }, "node_modules/laravel-vite-plugin": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz", @@ -2016,18 +1688,6 @@ "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", "license": "MIT" }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "license": "MIT" - }, - "node_modules/lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "license": "MIT" - }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -2059,16 +1719,10 @@ "mini-svg-data-uri": "cli.js" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -2149,9 +1803,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -2169,7 +1823,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2204,16 +1858,6 @@ "react": ">=16.0.0" } }, - "node_modules/pusher-js": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz", - "integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tweetnacl": "^1.0.3" - } - }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -2225,27 +1869,6 @@ "node": ">=0.10.0" } }, - "node_modules/redis-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "license": "MIT", - "dependencies": { - "redis-errors": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -2291,38 +1914,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/socket.io-client": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", - "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1", - "engine.io-client": "~6.6.1", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/socket.io-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", - "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2333,12 +1924,6 @@ "node": ">=0.10.0" } }, - "node_modules/standard-as-callback": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", - "license": "MIT" - }, "node_modules/tailwind-scrollbar": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-4.0.2.tgz", @@ -2392,13 +1977,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tweetnacl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", - "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", - "dev": true, - "license": "Unlicense" - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2503,61 +2081,6 @@ "funding": { "url": "https://github.com/sponsors/jonschlinkert" } - }, - "node_modules/vue": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", - "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.26", - "@vue/compiler-sfc": "3.5.26", - "@vue/runtime-dom": "3.5.26", - "@vue/server-renderer": "3.5.26", - "@vue/shared": "3.5.26" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xmlhttprequest-ssl": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", - "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.4.0" - } } } } diff --git a/package.json b/package.json index 6b0b58522..c3fb1bc5f 100644 --- a/package.json +++ b/package.json @@ -8,22 +8,17 @@ }, "devDependencies": { "@tailwindcss/postcss": "4.1.18", - "@vitejs/plugin-vue": "6.0.3", - "laravel-echo": "2.2.7", "laravel-vite-plugin": "2.0.1", - "postcss": "8.5.6", - "pusher-js": "8.4.0", + "postcss": "8.5.15", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.18", - "vite": "7.3.2", - "vue": "3.5.26" + "vite": "7.3.2" }, "dependencies": { "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.16", "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", - "ioredis": "5.6.1", "playwright": "^1.58.2" } } diff --git a/public/js/echo.js b/public/js/echo.js index 971662063..22f280301 100644 --- a/public/js/echo.js +++ b/public/js/echo.js @@ -1,2 +1,2 @@ -// Source: https://cdnjs.cloudflare.com/ajax/libs/laravel-echo/1.15.3/echo.iife.min.js -var Echo=function(){"use strict";function n(e){return(n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function o(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function i(e,t){for(var n=0;n{var s;e.startsWith("pusher:")||(s=String(this.options.namespace??"").replace(/\./g,"\\"),s=e.startsWith(s)?e.substring(s.length+1):"."+e,n(s,t))}),this}stopListening(e,t){return t?this.subscription.unbind(this.eventFormatter.format(e),t):this.subscription.unbind(this.eventFormatter.format(e)),this}stopListeningToAll(e){return e?this.subscription.unbind_global(e):this.subscription.unbind_global(),this}subscribed(e){return this.on("pusher:subscription_succeeded",()=>{e()}),this}error(t){return this.on("pusher:subscription_error",e=>{t(e)}),this}on(e,t){return this.subscription.bind(e,t),this}}class i extends s{whisper(e,t){return this.pusher.channels.channels[this.name].trigger("client-"+e,t),this}}class r extends s{whisper(e,t){return this.pusher.channels.channels[this.name].trigger("client-"+e,t),this}}class o extends i{here(e){return this.on("pusher:subscription_succeeded",t=>{e(Object.keys(t.members).map(e=>t.members[e]))}),this}joining(t){return this.on("pusher:member_added",e=>{t(e.info)}),this}whisper(e,t){return this.pusher.channels.channels[this.name].trigger("client-"+e,t),this}leaving(t){return this.on("pusher:member_removed",e=>{t(e.info)}),this}}class h extends t{constructor(e,t,s){super(),this.events={},this.listeners={},this.name=t,this.socket=e,this.options=s,this.eventFormatter=new n(this.options.namespace),this.subscribe()}subscribe(){this.socket.emit("subscribe",{channel:this.name,auth:this.options.auth||{}})}unsubscribe(){this.unbind(),this.socket.emit("unsubscribe",{channel:this.name,auth:this.options.auth||{}})}listen(e,t){return this.on(this.eventFormatter.format(e),t),this}stopListening(e,t){return this.unbindEvent(this.eventFormatter.format(e),t),this}subscribed(t){return this.on("connect",e=>{t(e)}),this}error(e){return this}on(s,e){return this.listeners[s]=this.listeners[s]||[],this.events[s]||(this.events[s]=(e,t)=>{this.name===e&&this.listeners[s]&&this.listeners[s].forEach(e=>e(t))},this.socket.on(s,this.events[s])),this.listeners[s].push(e),this}unbind(){Object.keys(this.events).forEach(e=>{this.unbindEvent(e)})}unbindEvent(e,t){this.listeners[e]=this.listeners[e]||[],t&&(this.listeners[e]=this.listeners[e].filter(e=>e!==t)),t&&0!==this.listeners[e].length||(this.events[e]&&(this.socket.removeListener(e,this.events[e]),delete this.events[e]),delete this.listeners[e])}}class c extends h{whisper(e,t){return this.socket.emit("client event",{channel:this.name,event:"client-"+e,data:t}),this}}class a extends c{here(t){return this.on("presence:subscribed",e=>{t(e.map(e=>e.user_info))}),this}joining(t){return this.on("presence:joining",e=>t(e.user_info)),this}whisper(e,t){return this.socket.emit("client event",{channel:this.name,event:"client-"+e,data:t}),this}leaving(t){return this.on("presence:leaving",e=>t(e.user_info)),this}}class u extends t{subscribe(){}unsubscribe(){}listen(e,t){return this}listenToAll(e){return this}stopListening(e,t){return this}subscribed(e){return this}error(e){return this}on(e,t){return this}}class l extends u{whisper(e,t){return this}}class p extends u{whisper(e,t){return this}}class d extends l{here(e){return this}joining(e){return this}whisper(e,t){return this}leaving(e){return this}}const b=class b{constructor(e){this.setOptions(e),this.connect()}setOptions(e){this.options={...b._defaultOptions,...e,broadcaster:e.broadcaster};let t=this.csrfToken();t&&(this.options.auth.headers["X-CSRF-TOKEN"]=t,this.options.userAuthentication.headers["X-CSRF-TOKEN"]=t),(t=this.options.bearerToken)&&(this.options.auth.headers.Authorization="Bearer "+t,this.options.userAuthentication.headers.Authorization="Bearer "+t)}csrfToken(){var e;return typeof window<"u"&&null!=(e=window.Laravel)&&e.csrfToken?window.Laravel.csrfToken:this.options.csrfToken||(typeof document<"u"&&"function"==typeof document.querySelector?(null==(e=document.querySelector('meta[name="csrf-token"]'))?void 0:e.getAttribute("content"))??null:null)}};b._defaultOptions={auth:{headers:{}},authEndpoint:"/broadcasting/auth",userAuthentication:{endpoint:"/broadcasting/user-auth",headers:{}},csrfToken:null,bearerToken:null,host:null,key:null,namespace:"App.Events"};var v=b;class f extends v{constructor(){super(...arguments),this.channels={}}connect(){if(typeof this.options.client<"u")this.pusher=this.options.client;else if(this.options.Pusher)this.pusher=new this.options.Pusher(this.options.key,this.options);else{if(!(typeof window<"u"&&typeof window.Pusher<"u"))throw new Error("Pusher client not found. Should be globally available or passed via options.client");this.pusher=new window.Pusher(this.options.key,this.options)}}signin(){this.pusher.signin()}listen(e,t,s){return this.channel(e).listen(t,s)}channel(e){return this.channels[e]||(this.channels[e]=new s(this.pusher,e,this.options)),this.channels[e]}privateChannel(e){return this.channels["private-"+e]||(this.channels["private-"+e]=new i(this.pusher,"private-"+e,this.options)),this.channels["private-"+e]}encryptedPrivateChannel(e){return this.channels["private-encrypted-"+e]||(this.channels["private-encrypted-"+e]=new r(this.pusher,"private-encrypted-"+e,this.options)),this.channels["private-encrypted-"+e]}presenceChannel(e){return this.channels["presence-"+e]||(this.channels["presence-"+e]=new o(this.pusher,"presence-"+e,this.options)),this.channels["presence-"+e]}leave(e){[e,"private-"+e,"private-encrypted-"+e,"presence-"+e].forEach(e=>{this.leaveChannel(e)})}leaveChannel(e){this.channels[e]&&(this.channels[e].unsubscribe(),delete this.channels[e])}socketId(){return this.pusher.connection.socket_id}disconnect(){this.pusher.disconnect()}}class m extends v{constructor(){super(...arguments),this.channels={}}connect(){let e=this.getSocketIO();this.socket=e(this.options.host??void 0,this.options),this.socket.io.on("reconnect",()=>{Object.values(this.channels).forEach(e=>{e.subscribe()})})}getSocketIO(){if(typeof this.options.client<"u")return this.options.client;if(typeof window<"u"&&typeof window.io<"u")return window.io;throw new Error("Socket.io client not found. Should be globally available or passed via options.client")}listen(e,t,s){return this.channel(e).listen(t,s)}channel(e){return this.channels[e]||(this.channels[e]=new h(this.socket,e,this.options)),this.channels[e]}privateChannel(e){return this.channels["private-"+e]||(this.channels["private-"+e]=new c(this.socket,"private-"+e,this.options)),this.channels["private-"+e]}presenceChannel(e){return this.channels["presence-"+e]||(this.channels["presence-"+e]=new a(this.socket,"presence-"+e,this.options)),this.channels["presence-"+e]}leave(e){[e,"private-"+e,"presence-"+e].forEach(e=>{this.leaveChannel(e)})}leaveChannel(e){this.channels[e]&&(this.channels[e].unsubscribe(),delete this.channels[e])}socketId(){return this.socket.id}disconnect(){this.socket.disconnect()}}class w extends v{constructor(){super(...arguments),this.channels={}}connect(){}listen(e,t,s){return new u}channel(e){return new u}privateChannel(e){return new l}encryptedPrivateChannel(e){return new p}presenceChannel(e){return new d}leave(e){}leaveChannel(e){}socketId(){return"fake-socket-id"}disconnect(){}}return e.Channel=t,e.Connector=v,e.EventFormatter=n,e.default=class{constructor(e){this.options=e,this.connect(),this.options.withoutInterceptors||this.registerInterceptors()}channel(e){return this.connector.channel(e)}connect(){if("reverb"===this.options.broadcaster)this.connector=new f({...this.options,cluster:""});else if("pusher"===this.options.broadcaster)this.connector=new f(this.options);else if("ably"===this.options.broadcaster)this.connector=new f({...this.options,cluster:"",broadcaster:"pusher"});else if("socket.io"===this.options.broadcaster)this.connector=new m(this.options);else if("null"===this.options.broadcaster)this.connector=new w(this.options);else{if("function"!=typeof this.options.broadcaster||!function(e){try{new e}catch(e){if(e instanceof Error&&e.message.includes("is not a constructor"))return}return 1}(this.options.broadcaster))throw new Error(`Broadcaster ${typeof this.options.broadcaster} ${String(this.options.broadcaster)} is not supported.`);this.connector=new this.options.broadcaster(this.options)}}disconnect(){this.connector.disconnect()}join(e){return this.connector.presenceChannel(e)}leave(e){this.connector.leave(e)}leaveChannel(e){this.connector.leaveChannel(e)}leaveAllChannels(){for(const e in this.connector.channels)this.leaveChannel(e)}listen(e,t,s){return this.connector.listen(e,t,s)}private(e){return this.connector.privateChannel(e)}encryptedPrivate(e){if(this.connectorSupportsEncryptedPrivateChannels(this.connector))return this.connector.encryptedPrivateChannel(e);throw new Error(`Broadcaster ${typeof this.options.broadcaster} ${String(this.options.broadcaster)} does not support encrypted private channels.`)}connectorSupportsEncryptedPrivateChannels(e){return e instanceof f||e instanceof w}socketId(){return this.connector.socketId()}registerInterceptors(){typeof Vue<"u"&&null!=Vue&&Vue.http&&this.registerVueRequestInterceptor(),"function"==typeof axios&&this.registerAxiosRequestInterceptor(),"function"==typeof jQuery&&this.registerjQueryAjaxSetup(),"object"==typeof Turbo&&this.registerTurboRequestInterceptor()}registerVueRequestInterceptor(){Vue.http.interceptors.push((e,t)=>{this.socketId()&&e.headers.set("X-Socket-ID",this.socketId()),t()})}registerAxiosRequestInterceptor(){axios.interceptors.request.use(e=>(this.socketId()&&(e.headers["X-Socket-Id"]=this.socketId()),e))}registerjQueryAjaxSetup(){typeof jQuery.ajax<"u"&&jQuery.ajaxPrefilter((e,t,s)=>{this.socketId()&&s.setRequestHeader("X-Socket-Id",this.socketId())})}registerTurboRequestInterceptor(){document.addEventListener("turbo:before-fetch-request",e=>{e.detail.fetchOptions.headers["X-Socket-Id"]=this.socketId()})}},Object.defineProperties(e,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}}),e}({}); diff --git a/public/js/pusher.js b/public/js/pusher.js index f18c77a4c..862e89bc0 100644 --- a/public/js/pusher.js +++ b/public/js/pusher.js @@ -1,10 +1,9 @@ /*! - * Pusher JavaScript Library v8.3.0 + * Pusher JavaScript Library v8.4.0 * https://pusher.com/ - * + * https://cdnjs.cloudflare.com/ajax/libs/pusher/8.4.0/pusher.min.js * Copyright 2020, Pusher * Released under the MIT licence. */ -// Source: https://cdnjs.cloudflare.com/ajax/libs/pusher/8.3.0/pusher.min.js -!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Pusher=e():t.Pusher=e()}(window,(function(){return function(t){var e={};function n(i){if(e[i])return e[i].exports;var r=e[i]={i:i,l:!1,exports:{}};return t[i].call(r.exports,r,r.exports,n),r.l=!0,r.exports}return n.m=t,n.c=e,n.d=function(t,e,i){n.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:i})},n.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},n.t=function(t,e){if(1&e&&(t=n(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var i=Object.create(null);if(n.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var r in t)n.d(i,r,function(e){return t[e]}.bind(null,r));return i},n.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return n.d(e,"a",e),e},n.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},n.p="",n(n.s=2)}([function(t,e,n){"use strict";var i,r=this&&this.__extends||(i=function(t,e){return(i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n])})(t,e)},function(t,e){function n(){this.constructor=t}i(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)});Object.defineProperty(e,"__esModule",{value:!0});var s=function(){function t(t){void 0===t&&(t="="),this._paddingCharacter=t}return t.prototype.encodedLength=function(t){return this._paddingCharacter?(t+2)/3*4|0:(8*t+5)/6|0},t.prototype.encode=function(t){for(var e="",n=0;n>>18&63),e+=this._encodeByte(i>>>12&63),e+=this._encodeByte(i>>>6&63),e+=this._encodeByte(i>>>0&63)}var r=t.length-n;if(r>0){i=t[n]<<16|(2===r?t[n+1]<<8:0);e+=this._encodeByte(i>>>18&63),e+=this._encodeByte(i>>>12&63),e+=2===r?this._encodeByte(i>>>6&63):this._paddingCharacter||"",e+=this._paddingCharacter||""}return e},t.prototype.maxDecodedLength=function(t){return this._paddingCharacter?t/4*3|0:(6*t+7)/8|0},t.prototype.decodedLength=function(t){return this.maxDecodedLength(t.length-this._getPaddingLength(t))},t.prototype.decode=function(t){if(0===t.length)return new Uint8Array(0);for(var e=this._getPaddingLength(t),n=t.length-e,i=new Uint8Array(this.maxDecodedLength(n)),r=0,s=0,o=0,a=0,c=0,h=0,u=0;s>>4,i[r++]=c<<4|h>>>2,i[r++]=h<<6|u,o|=256&a,o|=256&c,o|=256&h,o|=256&u;if(s>>4,o|=256&a,o|=256&c),s>>2,o|=256&h),s>>8&6,e+=51-t>>>8&-75,e+=61-t>>>8&-15,e+=62-t>>>8&3,String.fromCharCode(e)},t.prototype._decodeChar=function(t){var e=256;return e+=(42-t&t-44)>>>8&-256+t-43+62,e+=(46-t&t-48)>>>8&-256+t-47+63,e+=(47-t&t-58)>>>8&-256+t-48+52,e+=(64-t&t-91)>>>8&-256+t-65+0,e+=(96-t&t-123)>>>8&-256+t-97+26},t.prototype._getPaddingLength=function(t){var e=0;if(this._paddingCharacter){for(var n=t.length-1;n>=0&&t[n]===this._paddingCharacter;n--)e++;if(t.length<4||e>2)throw new Error("Base64Coder: incorrect padding")}return e},t}();e.Coder=s;var o=new s;e.encode=function(t){return o.encode(t)},e.decode=function(t){return o.decode(t)};var a=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return r(e,t),e.prototype._encodeByte=function(t){var e=t;return e+=65,e+=25-t>>>8&6,e+=51-t>>>8&-75,e+=61-t>>>8&-13,e+=62-t>>>8&49,String.fromCharCode(e)},e.prototype._decodeChar=function(t){var e=256;return e+=(44-t&t-46)>>>8&-256+t-45+62,e+=(94-t&t-96)>>>8&-256+t-95+63,e+=(47-t&t-58)>>>8&-256+t-48+52,e+=(64-t&t-91)>>>8&-256+t-65+0,e+=(96-t&t-123)>>>8&-256+t-97+26},e}(s);e.URLSafeCoder=a;var c=new a;e.encodeURLSafe=function(t){return c.encode(t)},e.decodeURLSafe=function(t){return c.decode(t)},e.encodedLength=function(t){return o.encodedLength(t)},e.maxDecodedLength=function(t){return o.maxDecodedLength(t)},e.decodedLength=function(t){return o.decodedLength(t)}},function(t,e,n){"use strict";Object.defineProperty(e,"__esModule",{value:!0});var i="utf8: invalid source encoding";function r(t){for(var e=0,n=0;n=t.length-1)throw new Error("utf8: invalid string");n++,e+=4}}return e}e.encode=function(t){for(var e=new Uint8Array(r(t)),n=0,i=0;i>6,e[n++]=128|63&s):s<55296?(e[n++]=224|s>>12,e[n++]=128|s>>6&63,e[n++]=128|63&s):(i++,s=(1023&s)<<10,s|=1023&t.charCodeAt(i),s+=65536,e[n++]=240|s>>18,e[n++]=128|s>>12&63,e[n++]=128|s>>6&63,e[n++]=128|63&s)}return e},e.encodedLength=r,e.decode=function(t){for(var e=[],n=0;n=t.length)throw new Error(i);if(128!=(192&(o=t[++n])))throw new Error(i);r=(31&r)<<6|63&o,s=128}else if(r<240){if(n>=t.length-1)throw new Error(i);var o=t[++n],a=t[++n];if(128!=(192&o)||128!=(192&a))throw new Error(i);r=(15&r)<<12|(63&o)<<6|63&a,s=2048}else{if(!(r<248))throw new Error(i);if(n>=t.length-2)throw new Error(i);o=t[++n],a=t[++n];var c=t[++n];if(128!=(192&o)||128!=(192&a)||128!=(192&c))throw new Error(i);r=(15&r)<<18|(63&o)<<12|(63&a)<<6|63&c,s=65536}if(r=55296&&r<=57343)throw new Error(i);if(r>=65536){if(r>1114111)throw new Error(i);r-=65536,e.push(String.fromCharCode(55296|r>>10)),r=56320|1023&r}}e.push(String.fromCharCode(r))}return e.join("")}},function(t,e,n){t.exports=n(3).default},function(t,e,n){"use strict";n.r(e);class i{constructor(t,e){this.lastId=0,this.prefix=t,this.name=e}create(t){this.lastId++;var e=this.lastId,n=this.prefix+e,i=this.name+"["+e+"]",r=!1,s=function(){r||(t.apply(null,arguments),r=!0)};return this[e]=s,{number:e,id:n,name:i,callback:s}}remove(t){delete this[t.number]}}var r=new i("_pusher_script_","Pusher.ScriptReceivers"),s={VERSION:"8.3.0",PROTOCOL:7,wsPort:80,wssPort:443,wsPath:"",httpHost:"sockjs.pusher.com",httpPort:80,httpsPort:443,httpPath:"/pusher",stats_host:"stats.pusher.com",authEndpoint:"/pusher/auth",authTransport:"ajax",activityTimeout:12e4,pongTimeout:3e4,unavailableTimeout:1e4,userAuthentication:{endpoint:"/pusher/user-auth",transport:"ajax"},channelAuthorization:{endpoint:"/pusher/auth",transport:"ajax"},cdn_http:"http://js.pusher.com",cdn_https:"https://js.pusher.com",dependency_suffix:""};var o=new i("_pusher_dependencies","Pusher.DependenciesReceivers"),a=new class{constructor(t){this.options=t,this.receivers=t.receivers||r,this.loading={}}load(t,e,n){var i=this;if(i.loading[t]&&i.loading[t].length>0)i.loading[t].push(n);else{i.loading[t]=[n];var r=ue.createScriptRequest(i.getPath(t,e)),s=i.receivers.create((function(e){if(i.receivers.remove(s),i.loading[t]){var n=i.loading[t];delete i.loading[t];for(var o=function(t){t||r.cleanup()},a=0;a>>6)+S(128|63&e):S(224|e>>>12&15)+S(128|e>>>6&63)+S(128|63&e)},E=function(t){return t.replace(/[^\x00-\x7F]/g,P)},O=function(t){var e=[0,2,1][t.length%3],n=t.charCodeAt(0)<<16|(t.length>1?t.charCodeAt(1):0)<<8|(t.length>2?t.charCodeAt(2):0);return[_.charAt(n>>>18),_.charAt(n>>>12&63),e>=2?"=":_.charAt(n>>>6&63),e>=1?"=":_.charAt(63&n)].join("")},x=window.btoa||function(t){return t.replace(/[\s\S]{1,3}/g,O)};var L=class{constructor(t,e,n,i){this.clear=e,this.timer=t(()=>{this.timer&&(this.timer=i(this.timer))},n)}isRunning(){return null!==this.timer}ensureAborted(){this.timer&&(this.clear(this.timer),this.timer=null)}};function A(t){window.clearTimeout(t)}function R(t){window.clearInterval(t)}class I extends L{constructor(t,e){super(setTimeout,A,t,(function(t){return e(),null}))}}class D extends L{constructor(t,e){super(setInterval,R,t,(function(t){return e(),t}))}}var j={now:()=>Date.now?Date.now():(new Date).valueOf(),defer:t=>new I(0,t),method(t,...e){var n=Array.prototype.slice.call(arguments,1);return function(e){return e[t].apply(e,n.concat(arguments))}}};function N(t,...e){for(var n=0;n{window.console&&window.console.log&&window.console.log(t)}}debug(...t){this.log(this.globalLog,t)}warn(...t){this.log(this.globalLogWarn,t)}error(...t){this.log(this.globalLogError,t)}globalLogWarn(t){window.console&&window.console.warn?window.console.warn(t):this.globalLog(t)}globalLogError(t){window.console&&window.console.error?window.console.error(t):this.globalLogWarn(t)}log(t,...e){var n=H.apply(this,arguments);if(Le.log)Le.log(n);else if(Le.logToConsole){t.bind(this)(n)}}},Y=function(t,e,n,i,r){void 0===n.headers&&null==n.headersProvider||V.warn(`To send headers with the ${i.toString()} request, you must use AJAX, rather than JSONP.`);var s=t.nextAuthCallbackID.toString();t.nextAuthCallbackID++;var o=t.getDocument(),a=o.createElement("script");t.auth_callbacks[s]=function(t){r(null,t)};var c="Pusher.auth_callbacks['"+s+"']";a.src=n.endpoint+"?callback="+encodeURIComponent(c)+"&"+e;var h=o.getElementsByTagName("head")[0]||o.documentElement;h.insertBefore(a,h.firstChild)};class Q{constructor(t){this.src=t}send(t){var e=this,n="Error loading "+e.src;e.script=document.createElement("script"),e.script.id=t.id,e.script.src=e.src,e.script.type="text/javascript",e.script.charset="UTF-8",e.script.addEventListener?(e.script.onerror=function(){t.callback(n)},e.script.onload=function(){t.callback(null)}):e.script.onreadystatechange=function(){"loaded"!==e.script.readyState&&"complete"!==e.script.readyState||t.callback(null)},void 0===e.script.async&&document.attachEvent&&/opera/i.test(navigator.userAgent)?(e.errorScript=document.createElement("script"),e.errorScript.id=t.id+"_error",e.errorScript.text=t.name+"('"+n+"');",e.script.async=e.errorScript.async=!1):e.script.async=!0;var i=document.getElementsByTagName("head")[0];i.insertBefore(e.script,i.firstChild),e.errorScript&&i.insertBefore(e.errorScript,e.script.nextSibling)}cleanup(){this.script&&(this.script.onload=this.script.onerror=null,this.script.onreadystatechange=null),this.script&&this.script.parentNode&&this.script.parentNode.removeChild(this.script),this.errorScript&&this.errorScript.parentNode&&this.errorScript.parentNode.removeChild(this.errorScript),this.script=null,this.errorScript=null}}class K{constructor(t,e){this.url=t,this.data=e}send(t){if(!this.request){var e=W(this.data),n=this.url+"/"+t.number+"?"+e;this.request=ue.createScriptRequest(n),this.request.send(t)}}cleanup(){this.request&&this.request.cleanup()}}var Z={name:"jsonp",getAgent:function(t,e){return function(n,i){var s="http"+(e?"s":"")+"://"+(t.host||t.options.host)+t.options.path,o=ue.createJSONPRequest(s,n),a=ue.ScriptReceivers.create((function(e,n){r.remove(a),o.cleanup(),n&&n.host&&(t.host=n.host),i&&i(e,n)}));o.send(a)}}};function tt(t,e,n){return t+(e.useTLS?"s":"")+"://"+(e.useTLS?e.hostTLS:e.hostNonTLS)+n}function et(t,e){return"/app/"+t+("?protocol="+s.PROTOCOL+"&client=js&version="+s.VERSION+(e?"&"+e:""))}var nt={getInitial:function(t,e){return tt("ws",e,(e.httpPath||"")+et(t,"flash=false"))}},it={getInitial:function(t,e){return tt("http",e,(e.httpPath||"/pusher")+et(t))}},rt={getInitial:function(t,e){return tt("http",e,e.httpPath||"/pusher")},getPath:function(t,e){return et(t)}};class st{constructor(){this._callbacks={}}get(t){return this._callbacks[ot(t)]}add(t,e,n){var i=ot(t);this._callbacks[i]=this._callbacks[i]||[],this._callbacks[i].push({fn:e,context:n})}remove(t,e,n){if(t||e||n){var i=t?[ot(t)]:z(this._callbacks);e||n?this.removeCallback(i,e,n):this.removeAllCallbacks(i)}else this._callbacks={}}removeCallback(t,e,n){q(t,(function(t){this._callbacks[t]=F(this._callbacks[t]||[],(function(t){return e&&e!==t.fn||n&&n!==t.context})),0===this._callbacks[t].length&&delete this._callbacks[t]}),this)}removeAllCallbacks(t){q(t,(function(t){delete this._callbacks[t]}),this)}}function ot(t){return"_"+t}class at{constructor(t){this.callbacks=new st,this.global_callbacks=[],this.failThrough=t}bind(t,e,n){return this.callbacks.add(t,e,n),this}bind_global(t){return this.global_callbacks.push(t),this}unbind(t,e,n){return this.callbacks.remove(t,e,n),this}unbind_global(t){return t?(this.global_callbacks=F(this.global_callbacks||[],e=>e!==t),this):(this.global_callbacks=[],this)}unbind_all(){return this.unbind(),this.unbind_global(),this}emit(t,e,n){for(var i=0;i0)for(i=0;i{this.onError(t),this.changeState("closed")}),!1}return this.bindListeners(),V.debug("Connecting",{transport:this.name,url:t}),this.changeState("connecting"),!0}close(){return!!this.socket&&(this.socket.close(),!0)}send(t){return"open"===this.state&&(j.defer(()=>{this.socket&&this.socket.send(t)}),!0)}ping(){"open"===this.state&&this.supportsPing()&&this.socket.ping()}onOpen(){this.hooks.beforeOpen&&this.hooks.beforeOpen(this.socket,this.hooks.urls.getPath(this.key,this.options)),this.changeState("open"),this.socket.onopen=void 0}onError(t){this.emit("error",{type:"WebSocketError",error:t}),this.timeline.error(this.buildTimelineMessage({error:t.toString()}))}onClose(t){t?this.changeState("closed",{code:t.code,reason:t.reason,wasClean:t.wasClean}):this.changeState("closed"),this.unbindListeners(),this.socket=void 0}onMessage(t){this.emit("message",t)}onActivity(){this.emit("activity")}bindListeners(){this.socket.onopen=()=>{this.onOpen()},this.socket.onerror=t=>{this.onError(t)},this.socket.onclose=t=>{this.onClose(t)},this.socket.onmessage=t=>{this.onMessage(t)},this.supportsPing()&&(this.socket.onactivity=()=>{this.onActivity()})}unbindListeners(){this.socket&&(this.socket.onopen=void 0,this.socket.onerror=void 0,this.socket.onclose=void 0,this.socket.onmessage=void 0,this.supportsPing()&&(this.socket.onactivity=void 0))}changeState(t,e){this.state=t,this.timeline.info(this.buildTimelineMessage({state:t,params:e})),this.emit(t,e)}buildTimelineMessage(t){return N({cid:this.id},t)}}class ht{constructor(t){this.hooks=t}isSupported(t){return this.hooks.isSupported(t)}createConnection(t,e,n,i){return new ct(this.hooks,t,e,n,i)}}var ut=new ht({urls:nt,handlesActivityChecks:!1,supportsPing:!1,isInitialized:function(){return Boolean(ue.getWebSocketAPI())},isSupported:function(){return Boolean(ue.getWebSocketAPI())},getSocket:function(t){return ue.createWebSocket(t)}}),lt={urls:it,handlesActivityChecks:!1,supportsPing:!0,isInitialized:function(){return!0}},dt=N({getSocket:function(t){return ue.HTTPFactory.createStreamingSocket(t)}},lt),pt=N({getSocket:function(t){return ue.HTTPFactory.createPollingSocket(t)}},lt),ft={isSupported:function(){return ue.isXHRSupported()}},gt={ws:ut,xhr_streaming:new ht(N({},dt,ft)),xhr_polling:new ht(N({},pt,ft))},vt=new ht({file:"sockjs",urls:rt,handlesActivityChecks:!0,supportsPing:!1,isSupported:function(){return!0},isInitialized:function(){return void 0!==window.SockJS},getSocket:function(t,e){return new window.SockJS(t,null,{js_path:a.getPath("sockjs",{useTLS:e.useTLS}),ignore_null_origin:e.ignoreNullOrigin})},beforeOpen:function(t,e){t.send(JSON.stringify({path:e}))}}),mt={isSupported:function(t){return ue.isXDRSupported(t.useTLS)}},bt=new ht(N({},dt,mt)),yt=new ht(N({},pt,mt));gt.xdr_streaming=bt,gt.xdr_polling=yt,gt.sockjs=vt;var wt=gt;var St=new class extends at{constructor(){super();var t=this;void 0!==window.addEventListener&&(window.addEventListener("online",(function(){t.emit("online")}),!1),window.addEventListener("offline",(function(){t.emit("offline")}),!1))}isOnline(){return void 0===window.navigator.onLine||window.navigator.onLine}};class _t{constructor(t,e,n){this.manager=t,this.transport=e,this.minPingDelay=n.minPingDelay,this.maxPingDelay=n.maxPingDelay,this.pingDelay=void 0}createConnection(t,e,n,i){i=N({},i,{activityTimeout:this.pingDelay});var r=this.transport.createConnection(t,e,n,i),s=null,o=function(){r.unbind("open",o),r.bind("closed",a),s=j.now()},a=t=>{if(r.unbind("closed",a),1002===t.code||1003===t.code)this.manager.reportDeath();else if(!t.wasClean&&s){var e=j.now()-s;e<2*this.maxPingDelay&&(this.manager.reportDeath(),this.pingDelay=Math.max(e/2,this.minPingDelay))}};return r.bind("open",o),r}isSupported(t){return this.manager.isAlive()&&this.transport.isSupported(t)}}const kt={decodeMessage:function(t){try{var e=JSON.parse(t.data),n=e.data;if("string"==typeof n)try{n=JSON.parse(e.data)}catch(t){}var i={event:e.event,channel:e.channel,data:n};return e.user_id&&(i.user_id=e.user_id),i}catch(e){throw{type:"MessageParseError",error:e,data:t.data}}},encodeMessage:function(t){return JSON.stringify(t)},processHandshake:function(t){var e=kt.decodeMessage(t);if("pusher:connection_established"===e.event){if(!e.data.activity_timeout)throw"No activity timeout specified in handshake";return{action:"connected",id:e.data.socket_id,activityTimeout:1e3*e.data.activity_timeout}}if("pusher:error"===e.event)return{action:this.getCloseAction(e.data),error:this.getCloseError(e.data)};throw"Invalid handshake"},getCloseAction:function(t){return t.code<4e3?t.code>=1002&&t.code<=1004?"backoff":null:4e3===t.code?"tls_only":t.code<4100?"refused":t.code<4200?"backoff":t.code<4300?"retry":"refused"},getCloseError:function(t){return 1e3!==t.code&&1001!==t.code?{type:"PusherError",data:{code:t.code,message:t.reason||t.message}}:null}};var Ct=kt;class Tt extends at{constructor(t,e){super(),this.id=t,this.transport=e,this.activityTimeout=e.activityTimeout,this.bindListeners()}handlesActivityChecks(){return this.transport.handlesActivityChecks()}send(t){return this.transport.send(t)}send_event(t,e,n){var i={event:t,data:e};return n&&(i.channel=n),V.debug("Event sent",i),this.send(Ct.encodeMessage(i))}ping(){this.transport.supportsPing()?this.transport.ping():this.send_event("pusher:ping",{})}close(){this.transport.close()}bindListeners(){var t={message:t=>{var e;try{e=Ct.decodeMessage(t)}catch(e){this.emit("error",{type:"MessageParseError",error:e,data:t.data})}if(void 0!==e){switch(V.debug("Event recd",e),e.event){case"pusher:error":this.emit("error",{type:"PusherError",data:e.data});break;case"pusher:ping":this.emit("ping");break;case"pusher:pong":this.emit("pong")}this.emit("message",e)}},activity:()=>{this.emit("activity")},error:t=>{this.emit("error",t)},closed:t=>{e(),t&&t.code&&this.handleCloseEvent(t),this.transport=null,this.emit("closed")}},e=()=>{M(t,(t,e)=>{this.transport.unbind(e,t)})};M(t,(t,e)=>{this.transport.bind(e,t)})}handleCloseEvent(t){var e=Ct.getCloseAction(t),n=Ct.getCloseError(t);n&&this.emit("error",n),e&&this.emit(e,{action:e,error:n})}}class Pt{constructor(t,e){this.transport=t,this.callback=e,this.bindListeners()}close(){this.unbindListeners(),this.transport.close()}bindListeners(){this.onMessage=t=>{var e;this.unbindListeners();try{e=Ct.processHandshake(t)}catch(t){return this.finish("error",{error:t}),void this.transport.close()}"connected"===e.action?this.finish("connected",{connection:new Tt(e.id,this.transport),activityTimeout:e.activityTimeout}):(this.finish(e.action,{error:e.error}),this.transport.close())},this.onClosed=t=>{this.unbindListeners();var e=Ct.getCloseAction(t)||"backoff",n=Ct.getCloseError(t);this.finish(e,{error:n})},this.transport.bind("message",this.onMessage),this.transport.bind("closed",this.onClosed)}unbindListeners(){this.transport.unbind("message",this.onMessage),this.transport.unbind("closed",this.onClosed)}finish(t,e){this.callback(N({transport:this.transport,action:t},e))}}class Et{constructor(t,e){this.timeline=t,this.options=e||{}}send(t,e){this.timeline.isEmpty()||this.timeline.send(ue.TimelineTransport.getAgent(this,t),e)}}class Ot extends at{constructor(t,e){super((function(e,n){V.debug("No callbacks on "+t+" for "+e)})),this.name=t,this.pusher=e,this.subscribed=!1,this.subscriptionPending=!1,this.subscriptionCancelled=!1}authorize(t,e){return e(null,{auth:""})}trigger(t,e){if(0!==t.indexOf("client-"))throw new l("Event '"+t+"' does not start with 'client-'");if(!this.subscribed){var n=u("triggeringClientEvents");V.warn("Client event triggered before channel 'subscription_succeeded' event . "+n)}return this.pusher.send_event(t,e,this.name)}disconnect(){this.subscribed=!1,this.subscriptionPending=!1}handleEvent(t){var e=t.event,n=t.data;if("pusher_internal:subscription_succeeded"===e)this.handleSubscriptionSucceededEvent(t);else if("pusher_internal:subscription_count"===e)this.handleSubscriptionCountEvent(t);else if(0!==e.indexOf("pusher_internal:")){this.emit(e,n,{})}}handleSubscriptionSucceededEvent(t){this.subscriptionPending=!1,this.subscribed=!0,this.subscriptionCancelled?this.pusher.unsubscribe(this.name):this.emit("pusher:subscription_succeeded",t.data)}handleSubscriptionCountEvent(t){t.data.subscription_count&&(this.subscriptionCount=t.data.subscription_count),this.emit("pusher:subscription_count",t.data)}subscribe(){this.subscribed||(this.subscriptionPending=!0,this.subscriptionCancelled=!1,this.authorize(this.pusher.connection.socket_id,(t,e)=>{t?(this.subscriptionPending=!1,V.error(t.toString()),this.emit("pusher:subscription_error",Object.assign({},{type:"AuthError",error:t.message},t instanceof y?{status:t.status}:{}))):this.pusher.send_event("pusher:subscribe",{auth:e.auth,channel_data:e.channel_data,channel:this.name})}))}unsubscribe(){this.subscribed=!1,this.pusher.send_event("pusher:unsubscribe",{channel:this.name})}cancelSubscription(){this.subscriptionCancelled=!0}reinstateSubscription(){this.subscriptionCancelled=!1}}class xt extends Ot{authorize(t,e){return this.pusher.config.channelAuthorizer({channelName:this.name,socketId:t},e)}}class Lt{constructor(){this.reset()}get(t){return Object.prototype.hasOwnProperty.call(this.members,t)?{id:t,info:this.members[t]}:null}each(t){M(this.members,(e,n)=>{t(this.get(n))})}setMyID(t){this.myID=t}onSubscription(t){this.members=t.presence.hash,this.count=t.presence.count,this.me=this.get(this.myID)}addMember(t){return null===this.get(t.user_id)&&this.count++,this.members[t.user_id]=t.user_info,this.get(t.user_id)}removeMember(t){var e=this.get(t.user_id);return e&&(delete this.members[t.user_id],this.count--),e}reset(){this.members={},this.count=0,this.myID=null,this.me=null}}var At=function(t,e,n,i){return new(n||(n=Promise))((function(r,s){function o(t){try{c(i.next(t))}catch(t){s(t)}}function a(t){try{c(i.throw(t))}catch(t){s(t)}}function c(t){var e;t.done?r(t.value):(e=t.value,e instanceof n?e:new n((function(t){t(e)}))).then(o,a)}c((i=i.apply(t,e||[])).next())}))};class Rt extends xt{constructor(t,e){super(t,e),this.members=new Lt}authorize(t,e){super.authorize(t,(t,n)=>At(this,void 0,void 0,(function*(){if(!t)if(null!=(n=n).channel_data){var i=JSON.parse(n.channel_data);this.members.setMyID(i.user_id)}else{if(yield this.pusher.user.signinDonePromise,null==this.pusher.user.user_data){let t=u("authorizationEndpoint");return V.error(`Invalid auth response for channel '${this.name}', expected 'channel_data' field. ${t}, or the user should be signed in.`),void e("Invalid auth response")}this.members.setMyID(this.pusher.user.user_data.id)}e(t,n)})))}handleEvent(t){var e=t.event;if(0===e.indexOf("pusher_internal:"))this.handleInternalEvent(t);else{var n=t.data,i={};t.user_id&&(i.user_id=t.user_id),this.emit(e,n,i)}}handleInternalEvent(t){var e=t.event,n=t.data;switch(e){case"pusher_internal:subscription_succeeded":this.handleSubscriptionSucceededEvent(t);break;case"pusher_internal:subscription_count":this.handleSubscriptionCountEvent(t);break;case"pusher_internal:member_added":var i=this.members.addMember(n);this.emit("pusher:member_added",i);break;case"pusher_internal:member_removed":var r=this.members.removeMember(n);r&&this.emit("pusher:member_removed",r)}}handleSubscriptionSucceededEvent(t){this.subscriptionPending=!1,this.subscribed=!0,this.subscriptionCancelled?this.pusher.unsubscribe(this.name):(this.members.onSubscription(t.data),this.emit("pusher:subscription_succeeded",this.members))}disconnect(){this.members.reset(),super.disconnect()}}var It=n(1),Dt=n(0);class jt extends xt{constructor(t,e,n){super(t,e),this.key=null,this.nacl=n}authorize(t,e){super.authorize(t,(t,n)=>{if(t)return void e(t,n);let i=n.shared_secret;i?(this.key=Object(Dt.decode)(i),delete n.shared_secret,e(null,n)):e(new Error("No shared_secret key in auth payload for encrypted channel: "+this.name),null)})}trigger(t,e){throw new v("Client events are not currently supported for encrypted channels")}handleEvent(t){var e=t.event,n=t.data;0!==e.indexOf("pusher_internal:")&&0!==e.indexOf("pusher:")?this.handleEncryptedEvent(e,n):super.handleEvent(t)}handleEncryptedEvent(t,e){if(!this.key)return void V.debug("Received encrypted event before key has been retrieved from the authEndpoint");if(!e.ciphertext||!e.nonce)return void V.error("Unexpected format for encrypted event, expected object with `ciphertext` and `nonce` fields, got: "+e);let n=Object(Dt.decode)(e.ciphertext);if(n.length{e?V.error(`Failed to make a request to the authEndpoint: ${s}. Unable to fetch new key, so dropping encrypted event`):(r=this.nacl.secretbox.open(n,i,this.key),null!==r?this.emit(t,this.getDataToEmit(r)):V.error("Failed to decrypt event with new key. Dropping encrypted event"))});this.emit(t,this.getDataToEmit(r))}getDataToEmit(t){let e=Object(It.decode)(t);try{return JSON.parse(e)}catch(t){return e}}}class Nt extends at{constructor(t,e){super(),this.state="initialized",this.connection=null,this.key=t,this.options=e,this.timeline=this.options.timeline,this.usingTLS=this.options.useTLS,this.errorCallbacks=this.buildErrorCallbacks(),this.connectionCallbacks=this.buildConnectionCallbacks(this.errorCallbacks),this.handshakeCallbacks=this.buildHandshakeCallbacks(this.errorCallbacks);var n=ue.getNetwork();n.bind("online",()=>{this.timeline.info({netinfo:"online"}),"connecting"!==this.state&&"unavailable"!==this.state||this.retryIn(0)}),n.bind("offline",()=>{this.timeline.info({netinfo:"offline"}),this.connection&&this.sendActivityCheck()}),this.updateStrategy()}connect(){this.connection||this.runner||(this.strategy.isSupported()?(this.updateState("connecting"),this.startConnecting(),this.setUnavailableTimer()):this.updateState("failed"))}send(t){return!!this.connection&&this.connection.send(t)}send_event(t,e,n){return!!this.connection&&this.connection.send_event(t,e,n)}disconnect(){this.disconnectInternally(),this.updateState("disconnected")}isUsingTLS(){return this.usingTLS}startConnecting(){var t=(e,n)=>{e?this.runner=this.strategy.connect(0,t):"error"===n.action?(this.emit("error",{type:"HandshakeError",error:n.error}),this.timeline.error({handshakeError:n.error})):(this.abortConnecting(),this.handshakeCallbacks[n.action](n))};this.runner=this.strategy.connect(0,t)}abortConnecting(){this.runner&&(this.runner.abort(),this.runner=null)}disconnectInternally(){(this.abortConnecting(),this.clearRetryTimer(),this.clearUnavailableTimer(),this.connection)&&this.abandonConnection().close()}updateStrategy(){this.strategy=this.options.getStrategy({key:this.key,timeline:this.timeline,useTLS:this.usingTLS})}retryIn(t){this.timeline.info({action:"retry",delay:t}),t>0&&this.emit("connecting_in",Math.round(t/1e3)),this.retryTimer=new I(t||0,()=>{this.disconnectInternally(),this.connect()})}clearRetryTimer(){this.retryTimer&&(this.retryTimer.ensureAborted(),this.retryTimer=null)}setUnavailableTimer(){this.unavailableTimer=new I(this.options.unavailableTimeout,()=>{this.updateState("unavailable")})}clearUnavailableTimer(){this.unavailableTimer&&this.unavailableTimer.ensureAborted()}sendActivityCheck(){this.stopActivityCheck(),this.connection.ping(),this.activityTimer=new I(this.options.pongTimeout,()=>{this.timeline.error({pong_timed_out:this.options.pongTimeout}),this.retryIn(0)})}resetActivityCheck(){this.stopActivityCheck(),this.connection&&!this.connection.handlesActivityChecks()&&(this.activityTimer=new I(this.activityTimeout,()=>{this.sendActivityCheck()}))}stopActivityCheck(){this.activityTimer&&this.activityTimer.ensureAborted()}buildConnectionCallbacks(t){return N({},t,{message:t=>{this.resetActivityCheck(),this.emit("message",t)},ping:()=>{this.send_event("pusher:pong",{})},activity:()=>{this.resetActivityCheck()},error:t=>{this.emit("error",t)},closed:()=>{this.abandonConnection(),this.shouldRetry()&&this.retryIn(1e3)}})}buildHandshakeCallbacks(t){return N({},t,{connected:t=>{this.activityTimeout=Math.min(this.options.activityTimeout,t.activityTimeout,t.connection.activityTimeout||1/0),this.clearUnavailableTimer(),this.setConnection(t.connection),this.socket_id=this.connection.id,this.updateState("connected",{socket_id:this.socket_id})}})}buildErrorCallbacks(){let t=t=>e=>{e.error&&this.emit("error",{type:"WebSocketError",error:e.error}),t(e)};return{tls_only:t(()=>{this.usingTLS=!0,this.updateStrategy(),this.retryIn(0)}),refused:t(()=>{this.disconnect()}),backoff:t(()=>{this.retryIn(1e3)}),retry:t(()=>{this.retryIn(0)})}}setConnection(t){for(var e in this.connection=t,this.connectionCallbacks)this.connection.bind(e,this.connectionCallbacks[e]);this.resetActivityCheck()}abandonConnection(){if(this.connection){for(var t in this.stopActivityCheck(),this.connectionCallbacks)this.connection.unbind(t,this.connectionCallbacks[t]);var e=this.connection;return this.connection=null,e}}updateState(t,e){var n=this.state;if(this.state=t,n!==t){var i=t;"connected"===i&&(i+=" with new socket ID "+e.socket_id),V.debug("State changed",n+" -> "+i),this.timeline.info({state:t,params:e}),this.emit("state_change",{previous:n,current:t}),this.emit(t,e)}}shouldRetry(){return"connecting"===this.state||"connected"===this.state}}class Ht{constructor(){this.channels={}}add(t,e){return this.channels[t]||(this.channels[t]=function(t,e){if(0===t.indexOf("private-encrypted-")){if(e.config.nacl)return Ut.createEncryptedChannel(t,e,e.config.nacl);let n="Tried to subscribe to a private-encrypted- channel but no nacl implementation available",i=u("encryptedChannelSupport");throw new v(`${n}. ${i}`)}if(0===t.indexOf("private-"))return Ut.createPrivateChannel(t,e);if(0===t.indexOf("presence-"))return Ut.createPresenceChannel(t,e);if(0===t.indexOf("#"))throw new d('Cannot create a channel with name "'+t+'".');return Ut.createChannel(t,e)}(t,e)),this.channels[t]}all(){return function(t){var e=[];return M(t,(function(t){e.push(t)})),e}(this.channels)}find(t){return this.channels[t]}remove(t){var e=this.channels[t];return delete this.channels[t],e}disconnect(){M(this.channels,(function(t){t.disconnect()}))}}var Ut={createChannels:()=>new Ht,createConnectionManager:(t,e)=>new Nt(t,e),createChannel:(t,e)=>new Ot(t,e),createPrivateChannel:(t,e)=>new xt(t,e),createPresenceChannel:(t,e)=>new Rt(t,e),createEncryptedChannel:(t,e,n)=>new jt(t,e,n),createTimelineSender:(t,e)=>new Et(t,e),createHandshake:(t,e)=>new Pt(t,e),createAssistantToTheTransportManager:(t,e,n)=>new _t(t,e,n)};class Mt{constructor(t){this.options=t||{},this.livesLeft=this.options.lives||1/0}getAssistant(t){return Ut.createAssistantToTheTransportManager(this,t,{minPingDelay:this.options.minPingDelay,maxPingDelay:this.options.maxPingDelay})}isAlive(){return this.livesLeft>0}reportDeath(){this.livesLeft-=1}}class zt{constructor(t,e){this.strategies=t,this.loop=Boolean(e.loop),this.failFast=Boolean(e.failFast),this.timeout=e.timeout,this.timeoutLimit=e.timeoutLimit}isSupported(){return J(this.strategies,j.method("isSupported"))}connect(t,e){var n=this.strategies,i=0,r=this.timeout,s=null,o=(a,c)=>{c?e(null,c):(i+=1,this.loop&&(i%=n.length),i0&&(r=new I(n.timeout,(function(){s.abort(),i(!0)}))),s=t.connect(e,(function(t,e){t&&r&&r.isRunning()&&!n.failFast||(r&&r.ensureAborted(),i(t,e))})),{abort:function(){r&&r.ensureAborted(),s.abort()},forceMinPriority:function(t){s.forceMinPriority(t)}}}}class qt{constructor(t){this.strategies=t}isSupported(){return J(this.strategies,j.method("isSupported"))}connect(t,e){return function(t,e,n){var i=B(t,(function(t,i,r,s){return t.connect(e,n(i,s))}));return{abort:function(){q(i,Bt)},forceMinPriority:function(t){q(i,(function(e){e.forceMinPriority(t)}))}}}(this.strategies,t,(function(t,n){return function(i,r){n[t].error=i,i?function(t){return function(t,e){for(var n=0;n=j.now()){var o=this.transports[i.transport];o&&(["ws","wss"].includes(i.transport)||r>3?(this.timeline.info({cached:!0,transport:i.transport,latency:i.latency}),s.push(new zt([o],{timeout:2*i.latency+1e3,failFast:!0}))):r++)}var a=j.now(),c=s.pop().connect(t,(function i(o,h){o?(Jt(n),s.length>0?(a=j.now(),c=s.pop().connect(t,i)):e(o)):(!function(t,e,n,i){var r=ue.getLocalStorage();if(r)try{r[Xt(t)]=G({timestamp:j.now(),transport:e,latency:n,cacheSkipCount:i})}catch(t){}}(n,h.transport.name,j.now()-a,r),e(null,h))}));return{abort:function(){c.abort()},forceMinPriority:function(e){t=e,c&&c.forceMinPriority(e)}}}}function Xt(t){return"pusherTransport"+(t?"TLS":"NonTLS")}function Jt(t){var e=ue.getLocalStorage();if(e)try{delete e[Xt(t)]}catch(t){}}class $t{constructor(t,{delay:e}){this.strategy=t,this.options={delay:e}}isSupported(){return this.strategy.isSupported()}connect(t,e){var n,i=this.strategy,r=new I(this.options.delay,(function(){n=i.connect(t,e)}));return{abort:function(){r.ensureAborted(),n&&n.abort()},forceMinPriority:function(e){t=e,n&&n.forceMinPriority(e)}}}}class Wt{constructor(t,e,n){this.test=t,this.trueBranch=e,this.falseBranch=n}isSupported(){return(this.test()?this.trueBranch:this.falseBranch).isSupported()}connect(t,e){return(this.test()?this.trueBranch:this.falseBranch).connect(t,e)}}class Gt{constructor(t){this.strategy=t}isSupported(){return this.strategy.isSupported()}connect(t,e){var n=this.strategy.connect(t,(function(t,i){i&&n.abort(),e(t,i)}));return n}}function Vt(t){return function(){return t.isSupported()}}var Yt=function(t,e,n){var i={};function r(e,r,s,o,a){var c=n(t,e,r,s,o,a);return i[e]=c,c}var s,o=Object.assign({},e,{hostNonTLS:t.wsHost+":"+t.wsPort,hostTLS:t.wsHost+":"+t.wssPort,httpPath:t.wsPath}),a=Object.assign({},o,{useTLS:!0}),c=Object.assign({},e,{hostNonTLS:t.httpHost+":"+t.httpPort,hostTLS:t.httpHost+":"+t.httpsPort,httpPath:t.httpPath}),h={loop:!0,timeout:15e3,timeoutLimit:6e4},u=new Mt({minPingDelay:1e4,maxPingDelay:t.activityTimeout}),l=new Mt({lives:2,minPingDelay:1e4,maxPingDelay:t.activityTimeout}),d=r("ws","ws",3,o,u),p=r("wss","ws",3,a,u),f=r("sockjs","sockjs",1,c),g=r("xhr_streaming","xhr_streaming",1,c,l),v=r("xdr_streaming","xdr_streaming",1,c,l),m=r("xhr_polling","xhr_polling",1,c),b=r("xdr_polling","xdr_polling",1,c),y=new zt([d],h),w=new zt([p],h),S=new zt([f],h),_=new zt([new Wt(Vt(g),g,v)],h),k=new zt([new Wt(Vt(m),m,b)],h),C=new zt([new Wt(Vt(_),new qt([_,new $t(k,{delay:4e3})]),k)],h),T=new Wt(Vt(C),C,S);return s=e.useTLS?new qt([y,new $t(T,{delay:2e3})]):new qt([y,new $t(w,{delay:2e3}),new $t(T,{delay:5e3})]),new Ft(new Gt(new Wt(Vt(d),s,T)),i,{ttl:18e5,timeline:e.timeline,useTLS:e.useTLS})},Qt={getRequest:function(t){var e=new window.XDomainRequest;return e.ontimeout=function(){t.emit("error",new p),t.close()},e.onerror=function(e){t.emit("error",e),t.close()},e.onprogress=function(){e.responseText&&e.responseText.length>0&&t.onChunk(200,e.responseText)},e.onload=function(){e.responseText&&e.responseText.length>0&&t.onChunk(200,e.responseText),t.emit("finished",200),t.close()},e},abortRequest:function(t){t.ontimeout=t.onerror=t.onprogress=t.onload=null,t.abort()}};class Kt extends at{constructor(t,e,n){super(),this.hooks=t,this.method=e,this.url=n}start(t){this.position=0,this.xhr=this.hooks.getRequest(this),this.unloader=()=>{this.close()},ue.addUnloadListener(this.unloader),this.xhr.open(this.method,this.url,!0),this.xhr.setRequestHeader&&this.xhr.setRequestHeader("Content-Type","application/json"),this.xhr.send(t)}close(){this.unloader&&(ue.removeUnloadListener(this.unloader),this.unloader=null),this.xhr&&(this.hooks.abortRequest(this.xhr),this.xhr=null)}onChunk(t,e){for(;;){var n=this.advanceBuffer(e);if(!n)break;this.emit("chunk",{status:t,data:n})}this.isBufferTooLong(e)&&this.emit("buffer_too_long")}advanceBuffer(t){var e=t.slice(this.position),n=e.indexOf("\n");return-1!==n?(this.position+=n+1,e.slice(0,n)):null}isBufferTooLong(t){return this.position===t.length&&t.length>262144}}var Zt;!function(t){t[t.CONNECTING=0]="CONNECTING",t[t.OPEN=1]="OPEN",t[t.CLOSED=3]="CLOSED"}(Zt||(Zt={}));var te=Zt,ee=1;function ne(t){var e=-1===t.indexOf("?")?"?":"&";return t+e+"t="+ +new Date+"&n="+ee++}function ie(t){return ue.randomInt(t)}var re,se=class{constructor(t,e){this.hooks=t,this.session=ie(1e3)+"/"+function(t){for(var e=[],n=0;n{this.onChunk(t)}),this.stream.bind("finished",t=>{this.hooks.onFinished(this,t)}),this.stream.bind("buffer_too_long",()=>{this.reconnect()});try{this.stream.start()}catch(t){j.defer(()=>{this.onError(t),this.onClose(1006,"Could not start streaming",!1)})}}closeStream(){this.stream&&(this.stream.unbind_all(),this.stream.close(),this.stream=null)}},oe={getReceiveURL:function(t,e){return t.base+"/"+e+"/xhr_streaming"+t.queryString},onHeartbeat:function(t){t.sendRaw("[]")},sendHeartbeat:function(t){t.sendRaw("[]")},onFinished:function(t,e){t.onClose(1006,"Connection interrupted ("+e+")",!1)}},ae={getReceiveURL:function(t,e){return t.base+"/"+e+"/xhr"+t.queryString},onHeartbeat:function(){},sendHeartbeat:function(t){t.sendRaw("[]")},onFinished:function(t,e){200===e?t.reconnect():t.onClose(1006,"Connection interrupted ("+e+")",!1)}},ce={getRequest:function(t){var e=new(ue.getXHRAPI());return e.onreadystatechange=e.onprogress=function(){switch(e.readyState){case 3:e.responseText&&e.responseText.length>0&&t.onChunk(e.status,e.responseText);break;case 4:e.responseText&&e.responseText.length>0&&t.onChunk(e.status,e.responseText),t.emit("finished",e.status),t.close()}},e},abortRequest:function(t){t.onreadystatechange=null,t.abort()}},he={createStreamingSocket(t){return this.createSocket(oe,t)},createPollingSocket(t){return this.createSocket(ae,t)},createSocket:(t,e)=>new se(t,e),createXHR(t,e){return this.createRequest(ce,t,e)},createRequest:(t,e,n)=>new Kt(t,e,n),createXDR:function(t,e){return this.createRequest(Qt,t,e)}},ue={nextAuthCallbackID:1,auth_callbacks:{},ScriptReceivers:r,DependenciesReceivers:o,getDefaultStrategy:Yt,Transports:wt,transportConnectionInitializer:function(){var t=this;t.timeline.info(t.buildTimelineMessage({transport:t.name+(t.options.useTLS?"s":"")})),t.hooks.isInitialized()?t.changeState("initialized"):t.hooks.file?(t.changeState("initializing"),a.load(t.hooks.file,{useTLS:t.options.useTLS},(function(e,n){t.hooks.isInitialized()?(t.changeState("initialized"),n(!0)):(e&&t.onError(e),t.onClose(),n(!1))}))):t.onClose()},HTTPFactory:he,TimelineTransport:Z,getXHRAPI:()=>window.XMLHttpRequest,getWebSocketAPI:()=>window.WebSocket||window.MozWebSocket,setup(t){window.Pusher=t;var e=()=>{this.onDocumentBody(t.ready)};window.JSON?e():a.load("json2",{},e)},getDocument:()=>document,getProtocol(){return this.getDocument().location.protocol},getAuthorizers:()=>({ajax:w,jsonp:Y}),onDocumentBody(t){document.body?t():setTimeout(()=>{this.onDocumentBody(t)},0)},createJSONPRequest:(t,e)=>new K(t,e),createScriptRequest:t=>new Q(t),getLocalStorage(){try{return window.localStorage}catch(t){return}},createXHR(){return this.getXHRAPI()?this.createXMLHttpRequest():this.createMicrosoftXHR()},createXMLHttpRequest(){return new(this.getXHRAPI())},createMicrosoftXHR:()=>new ActiveXObject("Microsoft.XMLHTTP"),getNetwork:()=>St,createWebSocket(t){return new(this.getWebSocketAPI())(t)},createSocketRequest(t,e){if(this.isXHRSupported())return this.HTTPFactory.createXHR(t,e);if(this.isXDRSupported(0===e.indexOf("https:")))return this.HTTPFactory.createXDR(t,e);throw"Cross-origin HTTP requests are not supported"},isXHRSupported(){var t=this.getXHRAPI();return Boolean(t)&&void 0!==(new t).withCredentials},isXDRSupported(t){var e=t?"https:":"http:",n=this.getProtocol();return Boolean(window.XDomainRequest)&&n===e},addUnloadListener(t){void 0!==window.addEventListener?window.addEventListener("unload",t,!1):void 0!==window.attachEvent&&window.attachEvent("onunload",t)},removeUnloadListener(t){void 0!==window.addEventListener?window.removeEventListener("unload",t,!1):void 0!==window.detachEvent&&window.detachEvent("onunload",t)},randomInt:t=>Math.floor((window.crypto||window.msCrypto).getRandomValues(new Uint32Array(1))[0]/Math.pow(2,32)*t)};!function(t){t[t.ERROR=3]="ERROR",t[t.INFO=6]="INFO",t[t.DEBUG=7]="DEBUG"}(re||(re={}));var le=re;class de{constructor(t,e,n){this.key=t,this.session=e,this.events=[],this.options=n||{},this.sent=0,this.uniqueID=0}log(t,e){t<=this.options.level&&(this.events.push(N({},e,{timestamp:j.now()})),this.options.limit&&this.events.length>this.options.limit&&this.events.shift())}error(t){this.log(le.ERROR,t)}info(t){this.log(le.INFO,t)}debug(t){this.log(le.DEBUG,t)}isEmpty(){return 0===this.events.length}send(t,e){var n=N({session:this.session,bundle:this.sent+1,key:this.key,lib:"js",version:this.options.version,cluster:this.options.cluster,features:this.options.features,timeline:this.events},this.options.params);return this.events=[],t(n,(t,n)=>{t||this.sent++,e&&e(t,n)}),!0}generateUniqueID(){return this.uniqueID++,this.uniqueID}}class pe{constructor(t,e,n,i){this.name=t,this.priority=e,this.transport=n,this.options=i||{}}isSupported(){return this.transport.isSupported({useTLS:this.options.useTLS})}connect(t,e){if(!this.isSupported())return fe(new b,e);if(this.priority{n||(h(),r?r.close():i.close())},forceMinPriority:t=>{n||this.priority{if(void 0===ue.getAuthorizers()[t.transport])throw`'${t.transport}' is not a recognized auth transport`;return(e,n)=>{const i=((t,e)=>{var n="socket_id="+encodeURIComponent(t.socketId);for(var i in e.params)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(e.params[i]);if(null!=e.paramsProvider){let t=e.paramsProvider();for(var i in t)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(t[i])}return n})(e,t);ue.getAuthorizers()[t.transport](ue,i,t,h.UserAuthentication,n)}};var ye=t=>{if(void 0===ue.getAuthorizers()[t.transport])throw`'${t.transport}' is not a recognized auth transport`;return(e,n)=>{const i=((t,e)=>{var n="socket_id="+encodeURIComponent(t.socketId);for(var i in n+="&channel_name="+encodeURIComponent(t.channelName),e.params)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(e.params[i]);if(null!=e.paramsProvider){let t=e.paramsProvider();for(var i in t)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(t[i])}return n})(e,t);ue.getAuthorizers()[t.transport](ue,i,t,h.ChannelAuthorization,n)}};function we(t){return t.httpHost?t.httpHost:t.cluster?`sockjs-${t.cluster}.pusher.com`:s.httpHost}function Se(t){return t.wsHost?t.wsHost:`ws-${t.cluster}.pusher.com`}function _e(t){return"https:"===ue.getProtocol()||!1!==t.forceTLS}function ke(t){return"enableStats"in t?t.enableStats:"disableStats"in t&&!t.disableStats}function Ce(t){const e=Object.assign(Object.assign({},s.userAuthentication),t.userAuthentication);return"customHandler"in e&&null!=e.customHandler?e.customHandler:be(e)}function Te(t,e){const n=function(t,e){let n;return"channelAuthorization"in t?n=Object.assign(Object.assign({},s.channelAuthorization),t.channelAuthorization):(n={transport:t.authTransport||s.authTransport,endpoint:t.authEndpoint||s.authEndpoint},"auth"in t&&("params"in t.auth&&(n.params=t.auth.params),"headers"in t.auth&&(n.headers=t.auth.headers)),"authorizer"in t&&(n.customHandler=((t,e,n)=>{const i={authTransport:e.transport,authEndpoint:e.endpoint,auth:{params:e.params,headers:e.headers}};return(e,r)=>{const s=t.channel(e.channelName);n(s,i).authorize(e.socketId,r)}})(e,n,t.authorizer))),n}(t,e);return"customHandler"in n&&null!=n.customHandler?n.customHandler:ye(n)}class Pe extends at{constructor(t){super((function(t,e){V.debug("No callbacks on watchlist events for "+t)})),this.pusher=t,this.bindWatchlistInternalEvent()}handleEvent(t){t.data.events.forEach(t=>{this.emit(t.name,t)})}bindWatchlistInternalEvent(){this.pusher.connection.bind("message",t=>{"pusher_internal:watchlist_events"===t.event&&this.handleEvent(t)})}}var Ee=function(){let t,e;return{promise:new Promise((n,i)=>{t=n,e=i}),resolve:t,reject:e}};class Oe extends at{constructor(t){super((function(t,e){V.debug("No callbacks on user for "+t)})),this.signin_requested=!1,this.user_data=null,this.serverToUserChannel=null,this.signinDonePromise=null,this._signinDoneResolve=null,this._onAuthorize=(t,e)=>{if(t)return V.warn("Error during signin: "+t),void this._cleanup();this.pusher.send_event("pusher:signin",{auth:e.auth,user_data:e.user_data})},this.pusher=t,this.pusher.connection.bind("state_change",({previous:t,current:e})=>{"connected"!==t&&"connected"===e&&this._signin(),"connected"===t&&"connected"!==e&&(this._cleanup(),this._newSigninPromiseIfNeeded())}),this.watchlist=new Pe(t),this.pusher.connection.bind("message",t=>{"pusher:signin_success"===t.event&&this._onSigninSuccess(t.data),this.serverToUserChannel&&this.serverToUserChannel.name===t.channel&&this.serverToUserChannel.handleEvent(t)})}signin(){this.signin_requested||(this.signin_requested=!0,this._signin())}_signin(){this.signin_requested&&(this._newSigninPromiseIfNeeded(),"connected"===this.pusher.connection.state&&this.pusher.config.userAuthenticator({socketId:this.pusher.connection.socket_id},this._onAuthorize))}_onSigninSuccess(t){try{this.user_data=JSON.parse(t.user_data)}catch(e){return V.error("Failed parsing user data after signin: "+t.user_data),void this._cleanup()}if("string"!=typeof this.user_data.id||""===this.user_data.id)return V.error("user_data doesn't contain an id. user_data: "+this.user_data),void this._cleanup();this._signinDoneResolve(),this._subscribeChannels()}_subscribeChannels(){this.serverToUserChannel=new Ot("#server-to-user-"+this.user_data.id,this.pusher),this.serverToUserChannel.bind_global((t,e)=>{0!==t.indexOf("pusher_internal:")&&0!==t.indexOf("pusher:")&&this.emit(t,e)}),(t=>{t.subscriptionPending&&t.subscriptionCancelled?t.reinstateSubscription():t.subscriptionPending||"connected"!==this.pusher.connection.state||t.subscribe()})(this.serverToUserChannel)}_cleanup(){this.user_data=null,this.serverToUserChannel&&(this.serverToUserChannel.unbind_all(),this.serverToUserChannel.disconnect(),this.serverToUserChannel=null),this.signin_requested&&this._signinDoneResolve()}_newSigninPromiseIfNeeded(){if(!this.signin_requested)return;if(this.signinDonePromise&&!this.signinDonePromise.done)return;const{promise:t,resolve:e,reject:n}=Ee();t.done=!1;const i=()=>{t.done=!0};t.then(i).catch(i),this.signinDonePromise=t,this._signinDoneResolve=e}}class xe{static ready(){xe.isReady=!0;for(var t=0,e=xe.instances.length;tue.getDefaultStrategy(this.config,t,ve),timeline:this.timeline,activityTimeout:this.config.activityTimeout,pongTimeout:this.config.pongTimeout,unavailableTimeout:this.config.unavailableTimeout,useTLS:Boolean(this.config.useTLS)}),this.connection.bind("connected",()=>{this.subscribeAll(),this.timelineSender&&this.timelineSender.send(this.connection.isUsingTLS())}),this.connection.bind("message",t=>{var e=0===t.event.indexOf("pusher_internal:");if(t.channel){var n=this.channel(t.channel);n&&n.handleEvent(t)}e||this.global_emitter.emit(t.event,t.data)}),this.connection.bind("connecting",()=>{this.channels.disconnect()}),this.connection.bind("disconnected",()=>{this.channels.disconnect()}),this.connection.bind("error",t=>{V.warn(t)}),xe.instances.push(this),this.timeline.info({instances:xe.instances.length}),this.user=new Oe(this),xe.isReady&&this.connect()}channel(t){return this.channels.find(t)}allChannels(){return this.channels.all()}connect(){if(this.connection.connect(),this.timelineSender&&!this.timelineSenderTimer){var t=this.connection.isUsingTLS(),e=this.timelineSender;this.timelineSenderTimer=new D(6e4,(function(){e.send(t)}))}}disconnect(){this.connection.disconnect(),this.timelineSenderTimer&&(this.timelineSenderTimer.ensureAborted(),this.timelineSenderTimer=null)}bind(t,e,n){return this.global_emitter.bind(t,e,n),this}unbind(t,e,n){return this.global_emitter.unbind(t,e,n),this}bind_global(t){return this.global_emitter.bind_global(t),this}unbind_global(t){return this.global_emitter.unbind_global(t),this}unbind_all(t){return this.global_emitter.unbind_all(),this}subscribeAll(){var t;for(t in this.channels.channels)this.channels.channels.hasOwnProperty(t)&&this.subscribe(t)}subscribe(t){var e=this.channels.add(t,this);return e.subscriptionPending&&e.subscriptionCancelled?e.reinstateSubscription():e.subscriptionPending||"connected"!==this.connection.state||e.subscribe(),e}unsubscribe(t){var e=this.channels.find(t);e&&e.subscriptionPending?e.cancelSubscription():(e=this.channels.remove(t))&&e.subscribed&&e.unsubscribe()}send_event(t,e,n){return this.connection.send_event(t,e,n)}shouldUseTLS(){return this.config.useTLS}signin(){this.user.signin()}}xe.instances=[],xe.isReady=!1,xe.logToConsole=!1,xe.Runtime=ue,xe.ScriptReceivers=ue.ScriptReceivers,xe.DependenciesReceivers=ue.DependenciesReceivers,xe.auth_callbacks=ue.auth_callbacks;var Le=e.default=xe;ue.setup(xe)}])})); +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Pusher=e():t.Pusher=e()}(window,(function(){return function(t){var e={};function n(i){if(e[i])return e[i].exports;var r=e[i]={i:i,l:!1,exports:{}};return t[i].call(r.exports,r,r.exports,n),r.l=!0,r.exports}return n.m=t,n.c=e,n.d=function(t,e,i){n.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:i})},n.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},n.t=function(t,e){if(1&e&&(t=n(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var i=Object.create(null);if(n.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var r in t)n.d(i,r,function(e){return t[e]}.bind(null,r));return i},n.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return n.d(e,"a",e),e},n.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},n.p="",n(n.s=2)}([function(t,e,n){"use strict";var i,r=this&&this.__extends||(i=function(t,e){return(i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n])})(t,e)},function(t,e){function n(){this.constructor=t}i(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)});Object.defineProperty(e,"__esModule",{value:!0});var s=function(){function t(t){void 0===t&&(t="="),this._paddingCharacter=t}return t.prototype.encodedLength=function(t){return this._paddingCharacter?(t+2)/3*4|0:(8*t+5)/6|0},t.prototype.encode=function(t){for(var e="",n=0;n>>18&63),e+=this._encodeByte(i>>>12&63),e+=this._encodeByte(i>>>6&63),e+=this._encodeByte(i>>>0&63)}var r=t.length-n;if(r>0){i=t[n]<<16|(2===r?t[n+1]<<8:0);e+=this._encodeByte(i>>>18&63),e+=this._encodeByte(i>>>12&63),e+=2===r?this._encodeByte(i>>>6&63):this._paddingCharacter||"",e+=this._paddingCharacter||""}return e},t.prototype.maxDecodedLength=function(t){return this._paddingCharacter?t/4*3|0:(6*t+7)/8|0},t.prototype.decodedLength=function(t){return this.maxDecodedLength(t.length-this._getPaddingLength(t))},t.prototype.decode=function(t){if(0===t.length)return new Uint8Array(0);for(var e=this._getPaddingLength(t),n=t.length-e,i=new Uint8Array(this.maxDecodedLength(n)),r=0,s=0,o=0,a=0,c=0,h=0,u=0;s>>4,i[r++]=c<<4|h>>>2,i[r++]=h<<6|u,o|=256&a,o|=256&c,o|=256&h,o|=256&u;if(s>>4,o|=256&a,o|=256&c),s>>2,o|=256&h),s>>8&6,e+=51-t>>>8&-75,e+=61-t>>>8&-15,e+=62-t>>>8&3,String.fromCharCode(e)},t.prototype._decodeChar=function(t){var e=256;return e+=(42-t&t-44)>>>8&-256+t-43+62,e+=(46-t&t-48)>>>8&-256+t-47+63,e+=(47-t&t-58)>>>8&-256+t-48+52,e+=(64-t&t-91)>>>8&-256+t-65+0,e+=(96-t&t-123)>>>8&-256+t-97+26},t.prototype._getPaddingLength=function(t){var e=0;if(this._paddingCharacter){for(var n=t.length-1;n>=0&&t[n]===this._paddingCharacter;n--)e++;if(t.length<4||e>2)throw new Error("Base64Coder: incorrect padding")}return e},t}();e.Coder=s;var o=new s;e.encode=function(t){return o.encode(t)},e.decode=function(t){return o.decode(t)};var a=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return r(e,t),e.prototype._encodeByte=function(t){var e=t;return e+=65,e+=25-t>>>8&6,e+=51-t>>>8&-75,e+=61-t>>>8&-13,e+=62-t>>>8&49,String.fromCharCode(e)},e.prototype._decodeChar=function(t){var e=256;return e+=(44-t&t-46)>>>8&-256+t-45+62,e+=(94-t&t-96)>>>8&-256+t-95+63,e+=(47-t&t-58)>>>8&-256+t-48+52,e+=(64-t&t-91)>>>8&-256+t-65+0,e+=(96-t&t-123)>>>8&-256+t-97+26},e}(s);e.URLSafeCoder=a;var c=new a;e.encodeURLSafe=function(t){return c.encode(t)},e.decodeURLSafe=function(t){return c.decode(t)},e.encodedLength=function(t){return o.encodedLength(t)},e.maxDecodedLength=function(t){return o.maxDecodedLength(t)},e.decodedLength=function(t){return o.decodedLength(t)}},function(t,e,n){"use strict";Object.defineProperty(e,"__esModule",{value:!0});var i="utf8: invalid source encoding";function r(t){for(var e=0,n=0;n=t.length-1)throw new Error("utf8: invalid string");n++,e+=4}}return e}e.encode=function(t){for(var e=new Uint8Array(r(t)),n=0,i=0;i>6,e[n++]=128|63&s):s<55296?(e[n++]=224|s>>12,e[n++]=128|s>>6&63,e[n++]=128|63&s):(i++,s=(1023&s)<<10,s|=1023&t.charCodeAt(i),s+=65536,e[n++]=240|s>>18,e[n++]=128|s>>12&63,e[n++]=128|s>>6&63,e[n++]=128|63&s)}return e},e.encodedLength=r,e.decode=function(t){for(var e=[],n=0;n=t.length)throw new Error(i);if(128!=(192&(o=t[++n])))throw new Error(i);r=(31&r)<<6|63&o,s=128}else if(r<240){if(n>=t.length-1)throw new Error(i);var o=t[++n],a=t[++n];if(128!=(192&o)||128!=(192&a))throw new Error(i);r=(15&r)<<12|(63&o)<<6|63&a,s=2048}else{if(!(r<248))throw new Error(i);if(n>=t.length-2)throw new Error(i);o=t[++n],a=t[++n];var c=t[++n];if(128!=(192&o)||128!=(192&a)||128!=(192&c))throw new Error(i);r=(15&r)<<18|(63&o)<<12|(63&a)<<6|63&c,s=65536}if(r=55296&&r<=57343)throw new Error(i);if(r>=65536){if(r>1114111)throw new Error(i);r-=65536,e.push(String.fromCharCode(55296|r>>10)),r=56320|1023&r}}e.push(String.fromCharCode(r))}return e.join("")}},function(t,e,n){t.exports=n(3).default},function(t,e,n){"use strict";n.r(e);class i{constructor(t,e){this.lastId=0,this.prefix=t,this.name=e}create(t){this.lastId++;var e=this.lastId,n=this.prefix+e,i=this.name+"["+e+"]",r=!1,s=function(){r||(t.apply(null,arguments),r=!0)};return this[e]=s,{number:e,id:n,name:i,callback:s}}remove(t){delete this[t.number]}}var r=new i("_pusher_script_","Pusher.ScriptReceivers"),s={VERSION:"8.4.0",PROTOCOL:7,wsPort:80,wssPort:443,wsPath:"",httpHost:"sockjs.pusher.com",httpPort:80,httpsPort:443,httpPath:"/pusher",stats_host:"stats.pusher.com",authEndpoint:"/pusher/auth",authTransport:"ajax",activityTimeout:12e4,pongTimeout:3e4,unavailableTimeout:1e4,userAuthentication:{endpoint:"/pusher/user-auth",transport:"ajax"},channelAuthorization:{endpoint:"/pusher/auth",transport:"ajax"},cdn_http:"http://js.pusher.com",cdn_https:"https://js.pusher.com",dependency_suffix:""};var o=new i("_pusher_dependencies","Pusher.DependenciesReceivers"),a=new class{constructor(t){this.options=t,this.receivers=t.receivers||r,this.loading={}}load(t,e,n){var i=this;if(i.loading[t]&&i.loading[t].length>0)i.loading[t].push(n);else{i.loading[t]=[n];var r=ue.createScriptRequest(i.getPath(t,e)),s=i.receivers.create((function(e){if(i.receivers.remove(s),i.loading[t]){var n=i.loading[t];delete i.loading[t];for(var o=function(t){t||r.cleanup()},a=0;a>>6)+S(128|63&e):S(224|e>>>12&15)+S(128|e>>>6&63)+S(128|63&e)},E=function(t){return t.replace(/[^\x00-\x7F]/g,P)},O=function(t){var e=[0,2,1][t.length%3],n=t.charCodeAt(0)<<16|(t.length>1?t.charCodeAt(1):0)<<8|(t.length>2?t.charCodeAt(2):0);return[_.charAt(n>>>18),_.charAt(n>>>12&63),e>=2?"=":_.charAt(n>>>6&63),e>=1?"=":_.charAt(63&n)].join("")},x=window.btoa||function(t){return t.replace(/[\s\S]{1,3}/g,O)};var L=class{constructor(t,e,n,i){this.clear=e,this.timer=t(()=>{this.timer&&(this.timer=i(this.timer))},n)}isRunning(){return null!==this.timer}ensureAborted(){this.timer&&(this.clear(this.timer),this.timer=null)}};function A(t){window.clearTimeout(t)}function R(t){window.clearInterval(t)}class I extends L{constructor(t,e){super(setTimeout,A,t,(function(t){return e(),null}))}}class D extends L{constructor(t,e){super(setInterval,R,t,(function(t){return e(),t}))}}var j={now:()=>Date.now?Date.now():(new Date).valueOf(),defer:t=>new I(0,t),method(t,...e){var n=Array.prototype.slice.call(arguments,1);return function(e){return e[t].apply(e,n.concat(arguments))}}};function N(t,...e){for(var n=0;n{window.console&&window.console.log&&window.console.log(t)}}debug(...t){this.log(this.globalLog,t)}warn(...t){this.log(this.globalLogWarn,t)}error(...t){this.log(this.globalLogError,t)}globalLogWarn(t){window.console&&window.console.warn?window.console.warn(t):this.globalLog(t)}globalLogError(t){window.console&&window.console.error?window.console.error(t):this.globalLogWarn(t)}log(t,...e){var n=H.apply(this,arguments);if(Le.log)Le.log(n);else if(Le.logToConsole){t.bind(this)(n)}}},Y=function(t,e,n,i,r){void 0===n.headers&&null==n.headersProvider||V.warn(`To send headers with the ${i.toString()} request, you must use AJAX, rather than JSONP.`);var s=t.nextAuthCallbackID.toString();t.nextAuthCallbackID++;var o=t.getDocument(),a=o.createElement("script");t.auth_callbacks[s]=function(t){r(null,t)};var c="Pusher.auth_callbacks['"+s+"']";a.src=n.endpoint+"?callback="+encodeURIComponent(c)+"&"+e;var h=o.getElementsByTagName("head")[0]||o.documentElement;h.insertBefore(a,h.firstChild)};class Q{constructor(t){this.src=t}send(t){var e=this,n="Error loading "+e.src;e.script=document.createElement("script"),e.script.id=t.id,e.script.src=e.src,e.script.type="text/javascript",e.script.charset="UTF-8",e.script.addEventListener?(e.script.onerror=function(){t.callback(n)},e.script.onload=function(){t.callback(null)}):e.script.onreadystatechange=function(){"loaded"!==e.script.readyState&&"complete"!==e.script.readyState||t.callback(null)},void 0===e.script.async&&document.attachEvent&&/opera/i.test(navigator.userAgent)?(e.errorScript=document.createElement("script"),e.errorScript.id=t.id+"_error",e.errorScript.text=t.name+"('"+n+"');",e.script.async=e.errorScript.async=!1):e.script.async=!0;var i=document.getElementsByTagName("head")[0];i.insertBefore(e.script,i.firstChild),e.errorScript&&i.insertBefore(e.errorScript,e.script.nextSibling)}cleanup(){this.script&&(this.script.onload=this.script.onerror=null,this.script.onreadystatechange=null),this.script&&this.script.parentNode&&this.script.parentNode.removeChild(this.script),this.errorScript&&this.errorScript.parentNode&&this.errorScript.parentNode.removeChild(this.errorScript),this.script=null,this.errorScript=null}}class K{constructor(t,e){this.url=t,this.data=e}send(t){if(!this.request){var e=W(this.data),n=this.url+"/"+t.number+"?"+e;this.request=ue.createScriptRequest(n),this.request.send(t)}}cleanup(){this.request&&this.request.cleanup()}}var Z={name:"jsonp",getAgent:function(t,e){return function(n,i){var s="http"+(e?"s":"")+"://"+(t.host||t.options.host)+t.options.path,o=ue.createJSONPRequest(s,n),a=ue.ScriptReceivers.create((function(e,n){r.remove(a),o.cleanup(),n&&n.host&&(t.host=n.host),i&&i(e,n)}));o.send(a)}}};function tt(t,e,n){return t+(e.useTLS?"s":"")+"://"+(e.useTLS?e.hostTLS:e.hostNonTLS)+n}function et(t,e){return"/app/"+t+("?protocol="+s.PROTOCOL+"&client=js&version="+s.VERSION+(e?"&"+e:""))}var nt={getInitial:function(t,e){return tt("ws",e,(e.httpPath||"")+et(t,"flash=false"))}},it={getInitial:function(t,e){return tt("http",e,(e.httpPath||"/pusher")+et(t))}},rt={getInitial:function(t,e){return tt("http",e,e.httpPath||"/pusher")},getPath:function(t,e){return et(t)}};class st{constructor(){this._callbacks={}}get(t){return this._callbacks[ot(t)]}add(t,e,n){var i=ot(t);this._callbacks[i]=this._callbacks[i]||[],this._callbacks[i].push({fn:e,context:n})}remove(t,e,n){if(t||e||n){var i=t?[ot(t)]:z(this._callbacks);e||n?this.removeCallback(i,e,n):this.removeAllCallbacks(i)}else this._callbacks={}}removeCallback(t,e,n){q(t,(function(t){this._callbacks[t]=F(this._callbacks[t]||[],(function(t){return e&&e!==t.fn||n&&n!==t.context})),0===this._callbacks[t].length&&delete this._callbacks[t]}),this)}removeAllCallbacks(t){q(t,(function(t){delete this._callbacks[t]}),this)}}function ot(t){return"_"+t}class at{constructor(t){this.callbacks=new st,this.global_callbacks=[],this.failThrough=t}bind(t,e,n){return this.callbacks.add(t,e,n),this}bind_global(t){return this.global_callbacks.push(t),this}unbind(t,e,n){return this.callbacks.remove(t,e,n),this}unbind_global(t){return t?(this.global_callbacks=F(this.global_callbacks||[],e=>e!==t),this):(this.global_callbacks=[],this)}unbind_all(){return this.unbind(),this.unbind_global(),this}emit(t,e,n){for(var i=0;i0)for(i=0;i{this.onError(t),this.changeState("closed")}),!1}return this.bindListeners(),V.debug("Connecting",{transport:this.name,url:t}),this.changeState("connecting"),!0}close(){return!!this.socket&&(this.socket.close(),!0)}send(t){return"open"===this.state&&(j.defer(()=>{this.socket&&this.socket.send(t)}),!0)}ping(){"open"===this.state&&this.supportsPing()&&this.socket.ping()}onOpen(){this.hooks.beforeOpen&&this.hooks.beforeOpen(this.socket,this.hooks.urls.getPath(this.key,this.options)),this.changeState("open"),this.socket.onopen=void 0}onError(t){this.emit("error",{type:"WebSocketError",error:t}),this.timeline.error(this.buildTimelineMessage({error:t.toString()}))}onClose(t){t?this.changeState("closed",{code:t.code,reason:t.reason,wasClean:t.wasClean}):this.changeState("closed"),this.unbindListeners(),this.socket=void 0}onMessage(t){this.emit("message",t)}onActivity(){this.emit("activity")}bindListeners(){this.socket.onopen=()=>{this.onOpen()},this.socket.onerror=t=>{this.onError(t)},this.socket.onclose=t=>{this.onClose(t)},this.socket.onmessage=t=>{this.onMessage(t)},this.supportsPing()&&(this.socket.onactivity=()=>{this.onActivity()})}unbindListeners(){this.socket&&(this.socket.onopen=void 0,this.socket.onerror=void 0,this.socket.onclose=void 0,this.socket.onmessage=void 0,this.supportsPing()&&(this.socket.onactivity=void 0))}changeState(t,e){this.state=t,this.timeline.info(this.buildTimelineMessage({state:t,params:e})),this.emit(t,e)}buildTimelineMessage(t){return N({cid:this.id},t)}}class ht{constructor(t){this.hooks=t}isSupported(t){return this.hooks.isSupported(t)}createConnection(t,e,n,i){return new ct(this.hooks,t,e,n,i)}}var ut=new ht({urls:nt,handlesActivityChecks:!1,supportsPing:!1,isInitialized:function(){return Boolean(ue.getWebSocketAPI())},isSupported:function(){return Boolean(ue.getWebSocketAPI())},getSocket:function(t){return ue.createWebSocket(t)}}),lt={urls:it,handlesActivityChecks:!1,supportsPing:!0,isInitialized:function(){return!0}},dt=N({getSocket:function(t){return ue.HTTPFactory.createStreamingSocket(t)}},lt),pt=N({getSocket:function(t){return ue.HTTPFactory.createPollingSocket(t)}},lt),ft={isSupported:function(){return ue.isXHRSupported()}},gt={ws:ut,xhr_streaming:new ht(N({},dt,ft)),xhr_polling:new ht(N({},pt,ft))},vt=new ht({file:"sockjs",urls:rt,handlesActivityChecks:!0,supportsPing:!1,isSupported:function(){return!0},isInitialized:function(){return void 0!==window.SockJS},getSocket:function(t,e){return new window.SockJS(t,null,{js_path:a.getPath("sockjs",{useTLS:e.useTLS}),ignore_null_origin:e.ignoreNullOrigin})},beforeOpen:function(t,e){t.send(JSON.stringify({path:e}))}}),mt={isSupported:function(t){return ue.isXDRSupported(t.useTLS)}},bt=new ht(N({},dt,mt)),yt=new ht(N({},pt,mt));gt.xdr_streaming=bt,gt.xdr_polling=yt,gt.sockjs=vt;var wt=gt;var St=new class extends at{constructor(){super();var t=this;void 0!==window.addEventListener&&(window.addEventListener("online",(function(){t.emit("online")}),!1),window.addEventListener("offline",(function(){t.emit("offline")}),!1))}isOnline(){return void 0===window.navigator.onLine||window.navigator.onLine}};class _t{constructor(t,e,n){this.manager=t,this.transport=e,this.minPingDelay=n.minPingDelay,this.maxPingDelay=n.maxPingDelay,this.pingDelay=void 0}createConnection(t,e,n,i){i=N({},i,{activityTimeout:this.pingDelay});var r=this.transport.createConnection(t,e,n,i),s=null,o=function(){r.unbind("open",o),r.bind("closed",a),s=j.now()},a=t=>{if(r.unbind("closed",a),1002===t.code||1003===t.code)this.manager.reportDeath();else if(!t.wasClean&&s){var e=j.now()-s;e<2*this.maxPingDelay&&(this.manager.reportDeath(),this.pingDelay=Math.max(e/2,this.minPingDelay))}};return r.bind("open",o),r}isSupported(t){return this.manager.isAlive()&&this.transport.isSupported(t)}}const kt={decodeMessage:function(t){try{var e=JSON.parse(t.data),n=e.data;if("string"==typeof n)try{n=JSON.parse(e.data)}catch(t){}var i={event:e.event,channel:e.channel,data:n};return e.user_id&&(i.user_id=e.user_id),i}catch(e){throw{type:"MessageParseError",error:e,data:t.data}}},encodeMessage:function(t){return JSON.stringify(t)},processHandshake:function(t){var e=kt.decodeMessage(t);if("pusher:connection_established"===e.event){if(!e.data.activity_timeout)throw"No activity timeout specified in handshake";return{action:"connected",id:e.data.socket_id,activityTimeout:1e3*e.data.activity_timeout}}if("pusher:error"===e.event)return{action:this.getCloseAction(e.data),error:this.getCloseError(e.data)};throw"Invalid handshake"},getCloseAction:function(t){return t.code<4e3?t.code>=1002&&t.code<=1004?"backoff":null:4e3===t.code?"tls_only":t.code<4100?"refused":t.code<4200?"backoff":t.code<4300?"retry":"refused"},getCloseError:function(t){return 1e3!==t.code&&1001!==t.code?{type:"PusherError",data:{code:t.code,message:t.reason||t.message}}:null}};var Ct=kt;class Tt extends at{constructor(t,e){super(),this.id=t,this.transport=e,this.activityTimeout=e.activityTimeout,this.bindListeners()}handlesActivityChecks(){return this.transport.handlesActivityChecks()}send(t){return this.transport.send(t)}send_event(t,e,n){var i={event:t,data:e};return n&&(i.channel=n),V.debug("Event sent",i),this.send(Ct.encodeMessage(i))}ping(){this.transport.supportsPing()?this.transport.ping():this.send_event("pusher:ping",{})}close(){this.transport.close()}bindListeners(){var t={message:t=>{var e;try{e=Ct.decodeMessage(t)}catch(e){this.emit("error",{type:"MessageParseError",error:e,data:t.data})}if(void 0!==e){switch(V.debug("Event recd",e),e.event){case"pusher:error":this.emit("error",{type:"PusherError",data:e.data});break;case"pusher:ping":this.emit("ping");break;case"pusher:pong":this.emit("pong")}this.emit("message",e)}},activity:()=>{this.emit("activity")},error:t=>{this.emit("error",t)},closed:t=>{e(),t&&t.code&&this.handleCloseEvent(t),this.transport=null,this.emit("closed")}},e=()=>{M(t,(t,e)=>{this.transport.unbind(e,t)})};M(t,(t,e)=>{this.transport.bind(e,t)})}handleCloseEvent(t){var e=Ct.getCloseAction(t),n=Ct.getCloseError(t);n&&this.emit("error",n),e&&this.emit(e,{action:e,error:n})}}class Pt{constructor(t,e){this.transport=t,this.callback=e,this.bindListeners()}close(){this.unbindListeners(),this.transport.close()}bindListeners(){this.onMessage=t=>{var e;this.unbindListeners();try{e=Ct.processHandshake(t)}catch(t){return this.finish("error",{error:t}),void this.transport.close()}"connected"===e.action?this.finish("connected",{connection:new Tt(e.id,this.transport),activityTimeout:e.activityTimeout}):(this.finish(e.action,{error:e.error}),this.transport.close())},this.onClosed=t=>{this.unbindListeners();var e=Ct.getCloseAction(t)||"backoff",n=Ct.getCloseError(t);this.finish(e,{error:n})},this.transport.bind("message",this.onMessage),this.transport.bind("closed",this.onClosed)}unbindListeners(){this.transport.unbind("message",this.onMessage),this.transport.unbind("closed",this.onClosed)}finish(t,e){this.callback(N({transport:this.transport,action:t},e))}}class Et{constructor(t,e){this.timeline=t,this.options=e||{}}send(t,e){this.timeline.isEmpty()||this.timeline.send(ue.TimelineTransport.getAgent(this,t),e)}}class Ot extends at{constructor(t,e){super((function(e,n){V.debug("No callbacks on "+t+" for "+e)})),this.name=t,this.pusher=e,this.subscribed=!1,this.subscriptionPending=!1,this.subscriptionCancelled=!1}authorize(t,e){return e(null,{auth:""})}trigger(t,e){if(0!==t.indexOf("client-"))throw new l("Event '"+t+"' does not start with 'client-'");if(!this.subscribed){var n=u("triggeringClientEvents");V.warn("Client event triggered before channel 'subscription_succeeded' event . "+n)}return this.pusher.send_event(t,e,this.name)}disconnect(){this.subscribed=!1,this.subscriptionPending=!1}handleEvent(t){var e=t.event,n=t.data;if("pusher_internal:subscription_succeeded"===e)this.handleSubscriptionSucceededEvent(t);else if("pusher_internal:subscription_count"===e)this.handleSubscriptionCountEvent(t);else if(0!==e.indexOf("pusher_internal:")){this.emit(e,n,{})}}handleSubscriptionSucceededEvent(t){this.subscriptionPending=!1,this.subscribed=!0,this.subscriptionCancelled?this.pusher.unsubscribe(this.name):this.emit("pusher:subscription_succeeded",t.data)}handleSubscriptionCountEvent(t){t.data.subscription_count&&(this.subscriptionCount=t.data.subscription_count),this.emit("pusher:subscription_count",t.data)}subscribe(){this.subscribed||(this.subscriptionPending=!0,this.subscriptionCancelled=!1,this.authorize(this.pusher.connection.socket_id,(t,e)=>{t?(this.subscriptionPending=!1,V.error(t.toString()),this.emit("pusher:subscription_error",Object.assign({},{type:"AuthError",error:t.message},t instanceof y?{status:t.status}:{}))):this.pusher.send_event("pusher:subscribe",{auth:e.auth,channel_data:e.channel_data,channel:this.name})}))}unsubscribe(){this.subscribed=!1,this.pusher.send_event("pusher:unsubscribe",{channel:this.name})}cancelSubscription(){this.subscriptionCancelled=!0}reinstateSubscription(){this.subscriptionCancelled=!1}}class xt extends Ot{authorize(t,e){return this.pusher.config.channelAuthorizer({channelName:this.name,socketId:t},e)}}class Lt{constructor(){this.reset()}get(t){return Object.prototype.hasOwnProperty.call(this.members,t)?{id:t,info:this.members[t]}:null}each(t){M(this.members,(e,n)=>{t(this.get(n))})}setMyID(t){this.myID=t}onSubscription(t){this.members=t.presence.hash,this.count=t.presence.count,this.me=this.get(this.myID)}addMember(t){return null===this.get(t.user_id)&&this.count++,this.members[t.user_id]=t.user_info,this.get(t.user_id)}removeMember(t){var e=this.get(t.user_id);return e&&(delete this.members[t.user_id],this.count--),e}reset(){this.members={},this.count=0,this.myID=null,this.me=null}}var At=function(t,e,n,i){return new(n||(n=Promise))((function(r,s){function o(t){try{c(i.next(t))}catch(t){s(t)}}function a(t){try{c(i.throw(t))}catch(t){s(t)}}function c(t){var e;t.done?r(t.value):(e=t.value,e instanceof n?e:new n((function(t){t(e)}))).then(o,a)}c((i=i.apply(t,e||[])).next())}))};class Rt extends xt{constructor(t,e){super(t,e),this.members=new Lt}authorize(t,e){super.authorize(t,(t,n)=>At(this,void 0,void 0,(function*(){if(!t)if(null!=(n=n).channel_data){var i=JSON.parse(n.channel_data);this.members.setMyID(i.user_id)}else{if(yield this.pusher.user.signinDonePromise,null==this.pusher.user.user_data){let t=u("authorizationEndpoint");return V.error(`Invalid auth response for channel '${this.name}', expected 'channel_data' field. ${t}, or the user should be signed in.`),void e("Invalid auth response")}this.members.setMyID(this.pusher.user.user_data.id)}e(t,n)})))}handleEvent(t){var e=t.event;if(0===e.indexOf("pusher_internal:"))this.handleInternalEvent(t);else{var n=t.data,i={};t.user_id&&(i.user_id=t.user_id),this.emit(e,n,i)}}handleInternalEvent(t){var e=t.event,n=t.data;switch(e){case"pusher_internal:subscription_succeeded":this.handleSubscriptionSucceededEvent(t);break;case"pusher_internal:subscription_count":this.handleSubscriptionCountEvent(t);break;case"pusher_internal:member_added":var i=this.members.addMember(n);this.emit("pusher:member_added",i);break;case"pusher_internal:member_removed":var r=this.members.removeMember(n);r&&this.emit("pusher:member_removed",r)}}handleSubscriptionSucceededEvent(t){this.subscriptionPending=!1,this.subscribed=!0,this.subscriptionCancelled?this.pusher.unsubscribe(this.name):(this.members.onSubscription(t.data),this.emit("pusher:subscription_succeeded",this.members))}disconnect(){this.members.reset(),super.disconnect()}}var It=n(1),Dt=n(0);class jt extends xt{constructor(t,e,n){super(t,e),this.key=null,this.nacl=n}authorize(t,e){super.authorize(t,(t,n)=>{if(t)return void e(t,n);let i=n.shared_secret;i?(this.key=Object(Dt.decode)(i),delete n.shared_secret,e(null,n)):e(new Error("No shared_secret key in auth payload for encrypted channel: "+this.name),null)})}trigger(t,e){throw new v("Client events are not currently supported for encrypted channels")}handleEvent(t){var e=t.event,n=t.data;0!==e.indexOf("pusher_internal:")&&0!==e.indexOf("pusher:")?this.handleEncryptedEvent(e,n):super.handleEvent(t)}handleEncryptedEvent(t,e){if(!this.key)return void V.debug("Received encrypted event before key has been retrieved from the authEndpoint");if(!e.ciphertext||!e.nonce)return void V.error("Unexpected format for encrypted event, expected object with `ciphertext` and `nonce` fields, got: "+e);let n=Object(Dt.decode)(e.ciphertext);if(n.length{e?V.error(`Failed to make a request to the authEndpoint: ${s}. Unable to fetch new key, so dropping encrypted event`):(r=this.nacl.secretbox.open(n,i,this.key),null!==r?this.emit(t,this.getDataToEmit(r)):V.error("Failed to decrypt event with new key. Dropping encrypted event"))});this.emit(t,this.getDataToEmit(r))}getDataToEmit(t){let e=Object(It.decode)(t);try{return JSON.parse(e)}catch(t){return e}}}class Nt extends at{constructor(t,e){super(),this.state="initialized",this.connection=null,this.key=t,this.options=e,this.timeline=this.options.timeline,this.usingTLS=this.options.useTLS,this.errorCallbacks=this.buildErrorCallbacks(),this.connectionCallbacks=this.buildConnectionCallbacks(this.errorCallbacks),this.handshakeCallbacks=this.buildHandshakeCallbacks(this.errorCallbacks);var n=ue.getNetwork();n.bind("online",()=>{this.timeline.info({netinfo:"online"}),"connecting"!==this.state&&"unavailable"!==this.state||this.retryIn(0)}),n.bind("offline",()=>{this.timeline.info({netinfo:"offline"}),this.connection&&this.sendActivityCheck()}),this.updateStrategy()}connect(){this.connection||this.runner||(this.strategy.isSupported()?(this.updateState("connecting"),this.startConnecting(),this.setUnavailableTimer()):this.updateState("failed"))}send(t){return!!this.connection&&this.connection.send(t)}send_event(t,e,n){return!!this.connection&&this.connection.send_event(t,e,n)}disconnect(){this.disconnectInternally(),this.updateState("disconnected")}isUsingTLS(){return this.usingTLS}startConnecting(){var t=(e,n)=>{e?this.runner=this.strategy.connect(0,t):"error"===n.action?(this.emit("error",{type:"HandshakeError",error:n.error}),this.timeline.error({handshakeError:n.error})):(this.abortConnecting(),this.handshakeCallbacks[n.action](n))};this.runner=this.strategy.connect(0,t)}abortConnecting(){this.runner&&(this.runner.abort(),this.runner=null)}disconnectInternally(){(this.abortConnecting(),this.clearRetryTimer(),this.clearUnavailableTimer(),this.connection)&&this.abandonConnection().close()}updateStrategy(){this.strategy=this.options.getStrategy({key:this.key,timeline:this.timeline,useTLS:this.usingTLS})}retryIn(t){this.timeline.info({action:"retry",delay:t}),t>0&&this.emit("connecting_in",Math.round(t/1e3)),this.retryTimer=new I(t||0,()=>{this.disconnectInternally(),this.connect()})}clearRetryTimer(){this.retryTimer&&(this.retryTimer.ensureAborted(),this.retryTimer=null)}setUnavailableTimer(){this.unavailableTimer=new I(this.options.unavailableTimeout,()=>{this.updateState("unavailable")})}clearUnavailableTimer(){this.unavailableTimer&&this.unavailableTimer.ensureAborted()}sendActivityCheck(){this.stopActivityCheck(),this.connection.ping(),this.activityTimer=new I(this.options.pongTimeout,()=>{this.timeline.error({pong_timed_out:this.options.pongTimeout}),this.retryIn(0)})}resetActivityCheck(){this.stopActivityCheck(),this.connection&&!this.connection.handlesActivityChecks()&&(this.activityTimer=new I(this.activityTimeout,()=>{this.sendActivityCheck()}))}stopActivityCheck(){this.activityTimer&&this.activityTimer.ensureAborted()}buildConnectionCallbacks(t){return N({},t,{message:t=>{this.resetActivityCheck(),this.emit("message",t)},ping:()=>{this.send_event("pusher:pong",{})},activity:()=>{this.resetActivityCheck()},error:t=>{this.emit("error",t)},closed:()=>{this.abandonConnection(),this.shouldRetry()&&this.retryIn(1e3)}})}buildHandshakeCallbacks(t){return N({},t,{connected:t=>{this.activityTimeout=Math.min(this.options.activityTimeout,t.activityTimeout,t.connection.activityTimeout||1/0),this.clearUnavailableTimer(),this.setConnection(t.connection),this.socket_id=this.connection.id,this.updateState("connected",{socket_id:this.socket_id})}})}buildErrorCallbacks(){let t=t=>e=>{e.error&&this.emit("error",{type:"WebSocketError",error:e.error}),t(e)};return{tls_only:t(()=>{this.usingTLS=!0,this.updateStrategy(),this.retryIn(0)}),refused:t(()=>{this.disconnect()}),backoff:t(()=>{this.retryIn(1e3)}),retry:t(()=>{this.retryIn(0)})}}setConnection(t){for(var e in this.connection=t,this.connectionCallbacks)this.connection.bind(e,this.connectionCallbacks[e]);this.resetActivityCheck()}abandonConnection(){if(this.connection){for(var t in this.stopActivityCheck(),this.connectionCallbacks)this.connection.unbind(t,this.connectionCallbacks[t]);var e=this.connection;return this.connection=null,e}}updateState(t,e){var n=this.state;if(this.state=t,n!==t){var i=t;"connected"===i&&(i+=" with new socket ID "+e.socket_id),V.debug("State changed",n+" -> "+i),this.timeline.info({state:t,params:e}),this.emit("state_change",{previous:n,current:t}),this.emit(t,e)}}shouldRetry(){return"connecting"===this.state||"connected"===this.state}}class Ht{constructor(){this.channels={}}add(t,e){return this.channels[t]||(this.channels[t]=function(t,e){if(0===t.indexOf("private-encrypted-")){if(e.config.nacl)return Ut.createEncryptedChannel(t,e,e.config.nacl);let n="Tried to subscribe to a private-encrypted- channel but no nacl implementation available",i=u("encryptedChannelSupport");throw new v(`${n}. ${i}`)}if(0===t.indexOf("private-"))return Ut.createPrivateChannel(t,e);if(0===t.indexOf("presence-"))return Ut.createPresenceChannel(t,e);if(0===t.indexOf("#"))throw new d('Cannot create a channel with name "'+t+'".');return Ut.createChannel(t,e)}(t,e)),this.channels[t]}all(){return function(t){var e=[];return M(t,(function(t){e.push(t)})),e}(this.channels)}find(t){return this.channels[t]}remove(t){var e=this.channels[t];return delete this.channels[t],e}disconnect(){M(this.channels,(function(t){t.disconnect()}))}}var Ut={createChannels:()=>new Ht,createConnectionManager:(t,e)=>new Nt(t,e),createChannel:(t,e)=>new Ot(t,e),createPrivateChannel:(t,e)=>new xt(t,e),createPresenceChannel:(t,e)=>new Rt(t,e),createEncryptedChannel:(t,e,n)=>new jt(t,e,n),createTimelineSender:(t,e)=>new Et(t,e),createHandshake:(t,e)=>new Pt(t,e),createAssistantToTheTransportManager:(t,e,n)=>new _t(t,e,n)};class Mt{constructor(t){this.options=t||{},this.livesLeft=this.options.lives||1/0}getAssistant(t){return Ut.createAssistantToTheTransportManager(this,t,{minPingDelay:this.options.minPingDelay,maxPingDelay:this.options.maxPingDelay})}isAlive(){return this.livesLeft>0}reportDeath(){this.livesLeft-=1}}class zt{constructor(t,e){this.strategies=t,this.loop=Boolean(e.loop),this.failFast=Boolean(e.failFast),this.timeout=e.timeout,this.timeoutLimit=e.timeoutLimit}isSupported(){return J(this.strategies,j.method("isSupported"))}connect(t,e){var n=this.strategies,i=0,r=this.timeout,s=null,o=(a,c)=>{c?e(null,c):(i+=1,this.loop&&(i%=n.length),i0&&(r=new I(n.timeout,(function(){s.abort(),i(!0)}))),s=t.connect(e,(function(t,e){t&&r&&r.isRunning()&&!n.failFast||(r&&r.ensureAborted(),i(t,e))})),{abort:function(){r&&r.ensureAborted(),s.abort()},forceMinPriority:function(t){s.forceMinPriority(t)}}}}class qt{constructor(t){this.strategies=t}isSupported(){return J(this.strategies,j.method("isSupported"))}connect(t,e){return function(t,e,n){var i=B(t,(function(t,i,r,s){return t.connect(e,n(i,s))}));return{abort:function(){q(i,Bt)},forceMinPriority:function(t){q(i,(function(e){e.forceMinPriority(t)}))}}}(this.strategies,t,(function(t,n){return function(i,r){n[t].error=i,i?function(t){return function(t,e){for(var n=0;n=j.now()){var o=this.transports[i.transport];o&&(["ws","wss"].includes(i.transport)||r>3?(this.timeline.info({cached:!0,transport:i.transport,latency:i.latency}),s.push(new zt([o],{timeout:2*i.latency+1e3,failFast:!0}))):r++)}var a=j.now(),c=s.pop().connect(t,(function i(o,h){o?(Jt(n),s.length>0?(a=j.now(),c=s.pop().connect(t,i)):e(o)):(!function(t,e,n,i){var r=ue.getLocalStorage();if(r)try{r[Xt(t)]=G({timestamp:j.now(),transport:e,latency:n,cacheSkipCount:i})}catch(t){}}(n,h.transport.name,j.now()-a,r),e(null,h))}));return{abort:function(){c.abort()},forceMinPriority:function(e){t=e,c&&c.forceMinPriority(e)}}}}function Xt(t){return"pusherTransport"+(t?"TLS":"NonTLS")}function Jt(t){var e=ue.getLocalStorage();if(e)try{delete e[Xt(t)]}catch(t){}}class $t{constructor(t,{delay:e}){this.strategy=t,this.options={delay:e}}isSupported(){return this.strategy.isSupported()}connect(t,e){var n,i=this.strategy,r=new I(this.options.delay,(function(){n=i.connect(t,e)}));return{abort:function(){r.ensureAborted(),n&&n.abort()},forceMinPriority:function(e){t=e,n&&n.forceMinPriority(e)}}}}class Wt{constructor(t,e,n){this.test=t,this.trueBranch=e,this.falseBranch=n}isSupported(){return(this.test()?this.trueBranch:this.falseBranch).isSupported()}connect(t,e){return(this.test()?this.trueBranch:this.falseBranch).connect(t,e)}}class Gt{constructor(t){this.strategy=t}isSupported(){return this.strategy.isSupported()}connect(t,e){var n=this.strategy.connect(t,(function(t,i){i&&n.abort(),e(t,i)}));return n}}function Vt(t){return function(){return t.isSupported()}}var Yt=function(t,e,n){var i={};function r(e,r,s,o,a){var c=n(t,e,r,s,o,a);return i[e]=c,c}var s,o=Object.assign({},e,{hostNonTLS:t.wsHost+":"+t.wsPort,hostTLS:t.wsHost+":"+t.wssPort,httpPath:t.wsPath}),a=Object.assign({},o,{useTLS:!0}),c=Object.assign({},e,{hostNonTLS:t.httpHost+":"+t.httpPort,hostTLS:t.httpHost+":"+t.httpsPort,httpPath:t.httpPath}),h={loop:!0,timeout:15e3,timeoutLimit:6e4},u=new Mt({minPingDelay:1e4,maxPingDelay:t.activityTimeout}),l=new Mt({lives:2,minPingDelay:1e4,maxPingDelay:t.activityTimeout}),d=r("ws","ws",3,o,u),p=r("wss","ws",3,a,u),f=r("sockjs","sockjs",1,c),g=r("xhr_streaming","xhr_streaming",1,c,l),v=r("xdr_streaming","xdr_streaming",1,c,l),m=r("xhr_polling","xhr_polling",1,c),b=r("xdr_polling","xdr_polling",1,c),y=new zt([d],h),w=new zt([p],h),S=new zt([f],h),_=new zt([new Wt(Vt(g),g,v)],h),k=new zt([new Wt(Vt(m),m,b)],h),C=new zt([new Wt(Vt(_),new qt([_,new $t(k,{delay:4e3})]),k)],h),T=new Wt(Vt(C),C,S);return s=e.useTLS?new qt([y,new $t(T,{delay:2e3})]):new qt([y,new $t(w,{delay:2e3}),new $t(T,{delay:5e3})]),new Ft(new Gt(new Wt(Vt(d),s,T)),i,{ttl:18e5,timeline:e.timeline,useTLS:e.useTLS})},Qt={getRequest:function(t){var e=new window.XDomainRequest;return e.ontimeout=function(){t.emit("error",new p),t.close()},e.onerror=function(e){t.emit("error",e),t.close()},e.onprogress=function(){e.responseText&&e.responseText.length>0&&t.onChunk(200,e.responseText)},e.onload=function(){e.responseText&&e.responseText.length>0&&t.onChunk(200,e.responseText),t.emit("finished",200),t.close()},e},abortRequest:function(t){t.ontimeout=t.onerror=t.onprogress=t.onload=null,t.abort()}};class Kt extends at{constructor(t,e,n){super(),this.hooks=t,this.method=e,this.url=n}start(t){this.position=0,this.xhr=this.hooks.getRequest(this),this.unloader=()=>{this.close()},ue.addUnloadListener(this.unloader),this.xhr.open(this.method,this.url,!0),this.xhr.setRequestHeader&&this.xhr.setRequestHeader("Content-Type","application/json"),this.xhr.send(t)}close(){this.unloader&&(ue.removeUnloadListener(this.unloader),this.unloader=null),this.xhr&&(this.hooks.abortRequest(this.xhr),this.xhr=null)}onChunk(t,e){for(;;){var n=this.advanceBuffer(e);if(!n)break;this.emit("chunk",{status:t,data:n})}this.isBufferTooLong(e)&&this.emit("buffer_too_long")}advanceBuffer(t){var e=t.slice(this.position),n=e.indexOf("\n");return-1!==n?(this.position+=n+1,e.slice(0,n)):null}isBufferTooLong(t){return this.position===t.length&&t.length>262144}}var Zt;!function(t){t[t.CONNECTING=0]="CONNECTING",t[t.OPEN=1]="OPEN",t[t.CLOSED=3]="CLOSED"}(Zt||(Zt={}));var te=Zt,ee=1;function ne(t){var e=-1===t.indexOf("?")?"?":"&";return t+e+"t="+ +new Date+"&n="+ee++}function ie(t){return ue.randomInt(t)}var re,se=class{constructor(t,e){this.hooks=t,this.session=ie(1e3)+"/"+function(t){for(var e=[],n=0;n{this.onChunk(t)}),this.stream.bind("finished",t=>{this.hooks.onFinished(this,t)}),this.stream.bind("buffer_too_long",()=>{this.reconnect()});try{this.stream.start()}catch(t){j.defer(()=>{this.onError(t),this.onClose(1006,"Could not start streaming",!1)})}}closeStream(){this.stream&&(this.stream.unbind_all(),this.stream.close(),this.stream=null)}},oe={getReceiveURL:function(t,e){return t.base+"/"+e+"/xhr_streaming"+t.queryString},onHeartbeat:function(t){t.sendRaw("[]")},sendHeartbeat:function(t){t.sendRaw("[]")},onFinished:function(t,e){t.onClose(1006,"Connection interrupted ("+e+")",!1)}},ae={getReceiveURL:function(t,e){return t.base+"/"+e+"/xhr"+t.queryString},onHeartbeat:function(){},sendHeartbeat:function(t){t.sendRaw("[]")},onFinished:function(t,e){200===e?t.reconnect():t.onClose(1006,"Connection interrupted ("+e+")",!1)}},ce={getRequest:function(t){var e=new(ue.getXHRAPI());return e.onreadystatechange=e.onprogress=function(){switch(e.readyState){case 3:e.responseText&&e.responseText.length>0&&t.onChunk(e.status,e.responseText);break;case 4:e.responseText&&e.responseText.length>0&&t.onChunk(e.status,e.responseText),t.emit("finished",e.status),t.close()}},e},abortRequest:function(t){t.onreadystatechange=null,t.abort()}},he={createStreamingSocket(t){return this.createSocket(oe,t)},createPollingSocket(t){return this.createSocket(ae,t)},createSocket:(t,e)=>new se(t,e),createXHR(t,e){return this.createRequest(ce,t,e)},createRequest:(t,e,n)=>new Kt(t,e,n),createXDR:function(t,e){return this.createRequest(Qt,t,e)}},ue={nextAuthCallbackID:1,auth_callbacks:{},ScriptReceivers:r,DependenciesReceivers:o,getDefaultStrategy:Yt,Transports:wt,transportConnectionInitializer:function(){var t=this;t.timeline.info(t.buildTimelineMessage({transport:t.name+(t.options.useTLS?"s":"")})),t.hooks.isInitialized()?t.changeState("initialized"):t.hooks.file?(t.changeState("initializing"),a.load(t.hooks.file,{useTLS:t.options.useTLS},(function(e,n){t.hooks.isInitialized()?(t.changeState("initialized"),n(!0)):(e&&t.onError(e),t.onClose(),n(!1))}))):t.onClose()},HTTPFactory:he,TimelineTransport:Z,getXHRAPI:()=>window.XMLHttpRequest,getWebSocketAPI:()=>window.WebSocket||window.MozWebSocket,setup(t){window.Pusher=t;var e=()=>{this.onDocumentBody(t.ready)};window.JSON?e():a.load("json2",{},e)},getDocument:()=>document,getProtocol(){return this.getDocument().location.protocol},getAuthorizers:()=>({ajax:w,jsonp:Y}),onDocumentBody(t){document.body?t():setTimeout(()=>{this.onDocumentBody(t)},0)},createJSONPRequest:(t,e)=>new K(t,e),createScriptRequest:t=>new Q(t),getLocalStorage(){try{return window.localStorage}catch(t){return}},createXHR(){return this.getXHRAPI()?this.createXMLHttpRequest():this.createMicrosoftXHR()},createXMLHttpRequest(){return new(this.getXHRAPI())},createMicrosoftXHR:()=>new ActiveXObject("Microsoft.XMLHTTP"),getNetwork:()=>St,createWebSocket(t){return new(this.getWebSocketAPI())(t)},createSocketRequest(t,e){if(this.isXHRSupported())return this.HTTPFactory.createXHR(t,e);if(this.isXDRSupported(0===e.indexOf("https:")))return this.HTTPFactory.createXDR(t,e);throw"Cross-origin HTTP requests are not supported"},isXHRSupported(){var t=this.getXHRAPI();return Boolean(t)&&void 0!==(new t).withCredentials},isXDRSupported(t){var e=t?"https:":"http:",n=this.getProtocol();return Boolean(window.XDomainRequest)&&n===e},addUnloadListener(t){void 0!==window.addEventListener?window.addEventListener("unload",t,!1):void 0!==window.attachEvent&&window.attachEvent("onunload",t)},removeUnloadListener(t){void 0!==window.addEventListener?window.removeEventListener("unload",t,!1):void 0!==window.detachEvent&&window.detachEvent("onunload",t)},randomInt:t=>Math.floor((window.crypto||window.msCrypto).getRandomValues(new Uint32Array(1))[0]/Math.pow(2,32)*t)};!function(t){t[t.ERROR=3]="ERROR",t[t.INFO=6]="INFO",t[t.DEBUG=7]="DEBUG"}(re||(re={}));var le=re;class de{constructor(t,e,n){this.key=t,this.session=e,this.events=[],this.options=n||{},this.sent=0,this.uniqueID=0}log(t,e){t<=this.options.level&&(this.events.push(N({},e,{timestamp:j.now()})),this.options.limit&&this.events.length>this.options.limit&&this.events.shift())}error(t){this.log(le.ERROR,t)}info(t){this.log(le.INFO,t)}debug(t){this.log(le.DEBUG,t)}isEmpty(){return 0===this.events.length}send(t,e){var n=N({session:this.session,bundle:this.sent+1,key:this.key,lib:"js",version:this.options.version,cluster:this.options.cluster,features:this.options.features,timeline:this.events},this.options.params);return this.events=[],t(n,(t,n)=>{t||this.sent++,e&&e(t,n)}),!0}generateUniqueID(){return this.uniqueID++,this.uniqueID}}class pe{constructor(t,e,n,i){this.name=t,this.priority=e,this.transport=n,this.options=i||{}}isSupported(){return this.transport.isSupported({useTLS:this.options.useTLS})}connect(t,e){if(!this.isSupported())return fe(new b,e);if(this.priority{n||(h(),r?r.close():i.close())},forceMinPriority:t=>{n||this.priority{if(void 0===ue.getAuthorizers()[t.transport])throw`'${t.transport}' is not a recognized auth transport`;return(e,n)=>{const i=((t,e)=>{var n="socket_id="+encodeURIComponent(t.socketId);for(var i in e.params)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(e.params[i]);if(null!=e.paramsProvider){let t=e.paramsProvider();for(var i in t)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(t[i])}return n})(e,t);ue.getAuthorizers()[t.transport](ue,i,t,h.UserAuthentication,n)}};var ye=t=>{if(void 0===ue.getAuthorizers()[t.transport])throw`'${t.transport}' is not a recognized auth transport`;return(e,n)=>{const i=((t,e)=>{var n="socket_id="+encodeURIComponent(t.socketId);for(var i in n+="&channel_name="+encodeURIComponent(t.channelName),e.params)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(e.params[i]);if(null!=e.paramsProvider){let t=e.paramsProvider();for(var i in t)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(t[i])}return n})(e,t);ue.getAuthorizers()[t.transport](ue,i,t,h.ChannelAuthorization,n)}};function we(t){return t.httpHost?t.httpHost:t.cluster?`sockjs-${t.cluster}.pusher.com`:s.httpHost}function Se(t){return t.wsHost?t.wsHost:`ws-${t.cluster}.pusher.com`}function _e(t){return"https:"===ue.getProtocol()||!1!==t.forceTLS}function ke(t){return"enableStats"in t?t.enableStats:"disableStats"in t&&!t.disableStats}function Ce(t){const e=Object.assign(Object.assign({},s.userAuthentication),t.userAuthentication);return"customHandler"in e&&null!=e.customHandler?e.customHandler:be(e)}function Te(t,e){const n=function(t,e){let n;return"channelAuthorization"in t?n=Object.assign(Object.assign({},s.channelAuthorization),t.channelAuthorization):(n={transport:t.authTransport||s.authTransport,endpoint:t.authEndpoint||s.authEndpoint},"auth"in t&&("params"in t.auth&&(n.params=t.auth.params),"headers"in t.auth&&(n.headers=t.auth.headers)),"authorizer"in t&&(n.customHandler=((t,e,n)=>{const i={authTransport:e.transport,authEndpoint:e.endpoint,auth:{params:e.params,headers:e.headers}};return(e,r)=>{const s=t.channel(e.channelName);n(s,i).authorize(e.socketId,r)}})(e,n,t.authorizer))),n}(t,e);return"customHandler"in n&&null!=n.customHandler?n.customHandler:ye(n)}class Pe extends at{constructor(t){super((function(t,e){V.debug("No callbacks on watchlist events for "+t)})),this.pusher=t,this.bindWatchlistInternalEvent()}handleEvent(t){t.data.events.forEach(t=>{this.emit(t.name,t)})}bindWatchlistInternalEvent(){this.pusher.connection.bind("message",t=>{"pusher_internal:watchlist_events"===t.event&&this.handleEvent(t)})}}var Ee=function(){let t,e;return{promise:new Promise((n,i)=>{t=n,e=i}),resolve:t,reject:e}};class Oe extends at{constructor(t){super((function(t,e){V.debug("No callbacks on user for "+t)})),this.signin_requested=!1,this.user_data=null,this.serverToUserChannel=null,this.signinDonePromise=null,this._signinDoneResolve=null,this._onAuthorize=(t,e)=>{if(t)return V.warn("Error during signin: "+t),void this._cleanup();this.pusher.send_event("pusher:signin",{auth:e.auth,user_data:e.user_data})},this.pusher=t,this.pusher.connection.bind("state_change",({previous:t,current:e})=>{"connected"!==t&&"connected"===e&&this._signin(),"connected"===t&&"connected"!==e&&(this._cleanup(),this._newSigninPromiseIfNeeded())}),this.watchlist=new Pe(t),this.pusher.connection.bind("message",t=>{"pusher:signin_success"===t.event&&this._onSigninSuccess(t.data),this.serverToUserChannel&&this.serverToUserChannel.name===t.channel&&this.serverToUserChannel.handleEvent(t)})}signin(){this.signin_requested||(this.signin_requested=!0,this._signin())}_signin(){this.signin_requested&&(this._newSigninPromiseIfNeeded(),"connected"===this.pusher.connection.state&&this.pusher.config.userAuthenticator({socketId:this.pusher.connection.socket_id},this._onAuthorize))}_onSigninSuccess(t){try{this.user_data=JSON.parse(t.user_data)}catch(e){return V.error("Failed parsing user data after signin: "+t.user_data),void this._cleanup()}if("string"!=typeof this.user_data.id||""===this.user_data.id)return V.error("user_data doesn't contain an id. user_data: "+this.user_data),void this._cleanup();this._signinDoneResolve(),this._subscribeChannels()}_subscribeChannels(){this.serverToUserChannel=new Ot("#server-to-user-"+this.user_data.id,this.pusher),this.serverToUserChannel.bind_global((t,e)=>{0!==t.indexOf("pusher_internal:")&&0!==t.indexOf("pusher:")&&this.emit(t,e)}),(t=>{t.subscriptionPending&&t.subscriptionCancelled?t.reinstateSubscription():t.subscriptionPending||"connected"!==this.pusher.connection.state||t.subscribe()})(this.serverToUserChannel)}_cleanup(){this.user_data=null,this.serverToUserChannel&&(this.serverToUserChannel.unbind_all(),this.serverToUserChannel.disconnect(),this.serverToUserChannel=null),this.signin_requested&&this._signinDoneResolve()}_newSigninPromiseIfNeeded(){if(!this.signin_requested)return;if(this.signinDonePromise&&!this.signinDonePromise.done)return;const{promise:t,resolve:e,reject:n}=Ee();t.done=!1;const i=()=>{t.done=!0};t.then(i).catch(i),this.signinDonePromise=t,this._signinDoneResolve=e}}class xe{static ready(){xe.isReady=!0;for(var t=0,e=xe.instances.length;tue.getDefaultStrategy(this.config,t,ve),timeline:this.timeline,activityTimeout:this.config.activityTimeout,pongTimeout:this.config.pongTimeout,unavailableTimeout:this.config.unavailableTimeout,useTLS:Boolean(this.config.useTLS)}),this.connection.bind("connected",()=>{this.subscribeAll(),this.timelineSender&&this.timelineSender.send(this.connection.isUsingTLS())}),this.connection.bind("message",t=>{var e=0===t.event.indexOf("pusher_internal:");if(t.channel){var n=this.channel(t.channel);n&&n.handleEvent(t)}e||this.global_emitter.emit(t.event,t.data)}),this.connection.bind("connecting",()=>{this.channels.disconnect()}),this.connection.bind("disconnected",()=>{this.channels.disconnect()}),this.connection.bind("error",t=>{V.warn(t)}),xe.instances.push(this),this.timeline.info({instances:xe.instances.length}),this.user=new Oe(this),xe.isReady&&this.connect()}channel(t){return this.channels.find(t)}allChannels(){return this.channels.all()}connect(){if(this.connection.connect(),this.timelineSender&&!this.timelineSenderTimer){var t=this.connection.isUsingTLS(),e=this.timelineSender;this.timelineSenderTimer=new D(6e4,(function(){e.send(t)}))}}disconnect(){this.connection.disconnect(),this.timelineSenderTimer&&(this.timelineSenderTimer.ensureAborted(),this.timelineSenderTimer=null)}bind(t,e,n){return this.global_emitter.bind(t,e,n),this}unbind(t,e,n){return this.global_emitter.unbind(t,e,n),this}bind_global(t){return this.global_emitter.bind_global(t),this}unbind_global(t){return this.global_emitter.unbind_global(t),this}unbind_all(t){return this.global_emitter.unbind_all(),this}subscribeAll(){var t;for(t in this.channels.channels)this.channels.channels.hasOwnProperty(t)&&this.subscribe(t)}subscribe(t){var e=this.channels.add(t,this);return e.subscriptionPending&&e.subscriptionCancelled?e.reinstateSubscription():e.subscriptionPending||"connected"!==this.connection.state||e.subscribe(),e}unsubscribe(t){var e=this.channels.find(t);e&&e.subscriptionPending?e.cancelSubscription():(e=this.channels.remove(t))&&e.subscribed&&e.unsubscribe()}send_event(t,e,n){return this.connection.send_event(t,e,n)}shouldUseTLS(){return this.config.useTLS}signin(){this.user.signin()}}xe.instances=[],xe.isReady=!1,xe.logToConsole=!1,xe.Runtime=ue,xe.ScriptReceivers=ue.ScriptReceivers,xe.DependenciesReceivers=ue.DependenciesReceivers,xe.auth_callbacks=ue.auth_callbacks;var Le=e.default=xe;ue.setup(xe)}])})); //# sourceMappingURL=pusher.min.js.map diff --git a/public/svgs/cloudflare-ddns.svg b/public/svgs/cloudflare-ddns.svg new file mode 100644 index 000000000..efe800bcc --- /dev/null +++ b/public/svgs/cloudflare-ddns.svg @@ -0,0 +1,8 @@ + + + + + + DDNS + + diff --git a/public/svgs/emqx-enterprise.svg b/public/svgs/emqx-enterprise.svg new file mode 100644 index 000000000..e67e1bffe --- /dev/null +++ b/public/svgs/emqx-enterprise.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/svgs/hermes-agent.png b/public/svgs/hermes-agent.png new file mode 100644 index 000000000..0d4a8e82a Binary files /dev/null and b/public/svgs/hermes-agent.png differ diff --git a/public/svgs/openobserve.svg b/public/svgs/openobserve.svg new file mode 100644 index 000000000..c687d948b --- /dev/null +++ b/public/svgs/openobserve.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/css/app.css b/resources/css/app.css index 936e0c713..de92bf0c9 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -53,6 +53,13 @@ @theme { If we ever want to remove these styles, we need to add an explicit border color utility to any element that depends on these defaults. */ + +@layer components { + .terminal-mobile-key { + @apply min-h-10 rounded-md border border-white/10 bg-white/10 px-2 py-2 text-sm font-semibold text-white shadow-inner active:bg-white/25; + } +} + @layer base { *, diff --git a/resources/js/terminal-session-timer.js b/resources/js/terminal-session-timer.js new file mode 100644 index 000000000..60c7f7311 --- /dev/null +++ b/resources/js/terminal-session-timer.js @@ -0,0 +1,22 @@ +export const MAX_TERMINAL_SESSION_SECONDS = 8 * 60 * 60; +export const TERMINAL_SESSION_WARNING_SECONDS = 30 * 60; +export const TERMINAL_SESSION_DANGER_SECONDS = 5 * 60; + +export function formatTerminalSessionRemainingTime(seconds) { + const remainingSeconds = Math.max(0, Math.ceil(seconds)); + + if (remainingSeconds === 0) { + return 'expired'; + } + + const totalMinutes = Math.floor(remainingSeconds / 60); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + const secondsPart = remainingSeconds % 60; + + if (hours === 0) { + return `${minutes}m ${String(secondsPart).padStart(2, '0')}s`; + } + + return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(secondsPart).padStart(2, '0')}s`; +} diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 7a7fc8536..9dc571e26 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -1,5 +1,11 @@ import { Terminal } from '@xterm/xterm'; import '@xterm/xterm/css/xterm.css'; +import { + MAX_TERMINAL_SESSION_SECONDS, + TERMINAL_SESSION_DANGER_SECONDS, + TERMINAL_SESSION_WARNING_SECONDS, + formatTerminalSessionRemainingTime, +} from './terminal-session-timer.js'; import { FitAddon } from '@xterm/addon-fit'; const terminalDebugEnabled = import.meta.env.DEV; @@ -44,7 +50,7 @@ export function initializeTerminalComponent() { pendingCommand: null, // Last successfully sent SSH command — replayed after a transient reconnect // so the PTY respawns automatically. Cleared on intentional terminations - // (pty-exited, idle-timeout, unprocessable). + // (pty-exited, unprocessable). lastSentCommand: null, // Resize handling resizeObserver: null, @@ -52,6 +58,10 @@ export function initializeTerminalComponent() { // Visibility handling - prevent disconnects when tab loses focus isDocumentVisible: true, wasConnectedBeforeHidden: false, + mobileToolbarCollapsed: false, + terminalSessionStartedAt: null, + terminalSessionRemainingSeconds: null, + terminalSessionCountdownInterval: null, init() { this.setupTerminal(); @@ -135,6 +145,7 @@ export function initializeTerminalComponent() { this.clearAllTimers(); this.connectionState = 'disconnected'; this.pendingCommand = null; + this.resetTerminalSessionCountdown(); if (this.socket) { this.socket.close(1000, 'Client cleanup'); } @@ -157,11 +168,68 @@ export function initializeTerminalComponent() { } [this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout] .forEach(timer => timer && clearTimeout(timer)); + if (this.terminalSessionCountdownInterval) { + clearInterval(this.terminalSessionCountdownInterval); + } this.keepAliveInterval = null; this.reconnectInterval = null; this.connectionTimeoutId = null; this.pingTimeoutId = null; this.resizeTimeout = null; + this.terminalSessionCountdownInterval = null; + }, + + resetTerminalSessionCountdown() { + if (this.terminalSessionCountdownInterval) { + clearInterval(this.terminalSessionCountdownInterval); + } + + this.terminalSessionStartedAt = null; + this.terminalSessionRemainingSeconds = null; + this.terminalSessionCountdownInterval = null; + }, + + startTerminalSessionCountdown() { + this.resetTerminalSessionCountdown(); + this.terminalSessionStartedAt = Date.now(); + this.updateTerminalSessionCountdown(); + this.terminalSessionCountdownInterval = setInterval(() => { + this.updateTerminalSessionCountdown(); + }, 1000); + }, + + updateTerminalSessionCountdown() { + if (!this.terminalSessionStartedAt) { + this.terminalSessionRemainingSeconds = null; + return; + } + + const elapsedSeconds = (Date.now() - this.terminalSessionStartedAt) / 1000; + this.terminalSessionRemainingSeconds = Math.max(0, MAX_TERMINAL_SESSION_SECONDS - elapsedSeconds); + }, + + terminalSessionRemainingLabel() { + if (this.terminalSessionRemainingSeconds === null) { + return ''; + } + + return `Session expires in ${formatTerminalSessionRemainingTime(this.terminalSessionRemainingSeconds)}`; + }, + + terminalSessionTimerClass() { + if (this.terminalSessionRemainingSeconds === null) { + return 'text-neutral-300 bg-black/70 border-white/10'; + } + + if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_DANGER_SECONDS) { + return 'text-red-200 bg-red-950/80 border-red-500/40'; + } + + if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_WARNING_SECONDS) { + return 'text-yellow-200 bg-yellow-950/80 border-yellow-500/40'; + } + + return 'text-neutral-300 bg-black/70 border-white/10'; }, resetTerminal() { @@ -181,6 +249,7 @@ export function initializeTerminalComponent() { this.paused = false; this.commandBuffer = ''; this.pendingCommand = null; + this.resetTerminalSessionCountdown(); // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); @@ -328,6 +397,7 @@ export function initializeTerminalComponent() { this.connectionState = 'disconnected'; this.clearAllTimers(); + this.resetTerminalSessionCountdown(); // Only reset terminal and reconnect if it wasn't a clean close if (event.code !== 1000) { @@ -424,6 +494,7 @@ export function initializeTerminalComponent() { } } this.terminalActive = true; + this.startTerminalSessionCountdown(); this.term.focus(); document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded-sm'); @@ -450,27 +521,22 @@ export function initializeTerminalComponent() { if (this.term) this.term.reset(); this.terminalActive = false; this.lastSentCommand = null; + this.resetTerminalSessionCountdown(); this.message = '(sorry, something went wrong, please try again)'; // Notify parent component that terminal connection failed this.$wire.dispatch('terminalDisconnected'); } else if (event.data === 'pty-exited') { + this.fullscreen = false; + this.mobileToolbarCollapsed = false; this.terminalActive = false; + this.resetTerminalSessionCountdown(); this.term.reset(); this.commandBuffer = ''; this.lastSentCommand = null; // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); - } else if (event.data === 'idle-timeout') { - this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.'); - this.terminalActive = false; - if (this.term) { - this.term.reset(); - } - this.commandBuffer = ''; - this.lastSentCommand = null; - this.$wire.dispatch('terminalDisconnected'); } else if ( typeof event.data === 'string' && (event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:')) @@ -478,6 +544,7 @@ export function initializeTerminalComponent() { logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data); this.$wire.dispatch('error', event.data); this.terminalActive = false; + this.resetTerminalSessionCountdown(); } else { try { this.pendingWrites++; @@ -538,6 +605,64 @@ export function initializeTerminalComponent() { }); }, + + sendTerminalInput(data) { + if (!this.term || !this.terminalActive) { + return; + } + + this.term.focus(); + this.sendMessage({ message: data }); + }, + + sendTerminalControl(sequence) { + const terminalSequences = { + arrowUp: '\x1b[A', + arrowDown: '\x1b[B', + arrowRight: '\x1b[C', + arrowLeft: '\x1b[D', + tab: '\t', + escape: '\x1b', + ctrlC: '\x03' + }; + + if (terminalSequences[sequence]) { + this.sendTerminalInput(terminalSequences[sequence]); + } + }, + + async pasteFromClipboard() { + if (!navigator.clipboard?.readText) { + this.$wire.dispatch('error', 'Clipboard paste is not available in this browser.'); + return; + } + + try { + const text = await navigator.clipboard.readText(); + if (text) { + this.sendTerminalInput(text); + } + } catch (error) { + logTerminal('warn', '[Terminal] Clipboard paste failed:', error); + this.$wire.dispatch('error', 'Clipboard paste permission was denied.'); + } + }, + + async copyTerminalSelection() { + const selection = this.term?.getSelection(); + if (!selection) { + this.$wire.dispatch('error', 'Select terminal text before copying.'); + return; + } + + try { + await navigator.clipboard.writeText(selection); + } catch (error) { + logTerminal('warn', '[Terminal] Clipboard copy failed:', error); + this.$wire.dispatch('error', 'Clipboard copy permission was denied.'); + } + }, + keepAlive() { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.sendMessage({ ping: true }); @@ -629,15 +754,20 @@ export function initializeTerminalComponent() { // Force a refresh of the fit addon dimensions this.fitAddon.fit(); - // Get fresh dimensions after fit - const wrapperHeight = this.$refs.terminalWrapper.clientHeight; - const wrapperWidth = this.$refs.terminalWrapper.clientWidth; + // Get fresh dimensions from the terminal element itself. The mobile + // toolbar can live beside the terminal in normal flow, so wrapper dimensions + // would include controls that should not be counted as terminal rows. + const terminalElement = document.getElementById('terminal'); + const terminalHeight = terminalElement?.clientHeight || this.$refs.terminalWrapper.clientHeight; + const terminalWidth = terminalElement?.clientWidth || this.$refs.terminalWrapper.clientWidth; - // Account for terminal container padding (px-2 py-1 = 8px left/right, 4px top/bottom) - const horizontalPadding = 16; // 8px * 2 (left + right) - const verticalPadding = 8; // 4px * 2 (top + bottom) - const height = wrapperHeight - verticalPadding; - const width = wrapperWidth - horizontalPadding; + // Account for terminal container padding. In fullscreen mobile mode, + // the fixed toolbar sits over the terminal container, so reserve its height + // when calculating rows to keep the prompt above the controls. + const horizontalPadding = 16; // px-2 = 8px * 2 (left + right) + const verticalPadding = 8; // py-1 = 4px * 2 (top + bottom) + const height = terminalHeight - verticalPadding; + const width = terminalWidth - horizontalPadding; // Check if dimensions are valid if (height <= 0 || width <= 0) { diff --git a/resources/js/terminal.test.js b/resources/js/terminal.test.js new file mode 100644 index 000000000..e0a4fb852 --- /dev/null +++ b/resources/js/terminal.test.js @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + MAX_TERMINAL_SESSION_SECONDS, + formatTerminalSessionRemainingTime, +} from './terminal-session-timer.js'; + +test('formatTerminalSessionRemainingTime formats the eight hour terminal limit countdown', () => { + assert.equal(MAX_TERMINAL_SESSION_SECONDS, 8 * 60 * 60); + assert.equal(formatTerminalSessionRemainingTime(MAX_TERMINAL_SESSION_SECONDS), '8h 00m 00s'); + assert.equal(formatTerminalSessionRemainingTime((7 * 60 * 60) + (59 * 60) + 59), '7h 59m 59s'); + assert.equal(formatTerminalSessionRemainingTime(65 * 60), '1h 05m 00s'); + assert.equal(formatTerminalSessionRemainingTime(59), '0m 59s'); + assert.equal(formatTerminalSessionRemainingTime(0), 'expired'); +}); diff --git a/resources/views/components/database-status-info.blade.php b/resources/views/components/database-status-info.blade.php new file mode 100644 index 000000000..24116db25 --- /dev/null +++ b/resources/views/components/database-status-info.blade.php @@ -0,0 +1,100 @@ +@props([ + 'database', + 'label', + 'dbUrl' => null, + 'dbUrlPublic' => null, + 'supportsSsl' => true, + 'enableSsl' => false, + 'sslMode' => null, + 'sslModeOptions' => null, + 'sslModeHelper' => null, + 'certificateValidUntil' => null, + 'isExited' => false, + 'showPublicUrlPlaceholder' => false, + 'isPasswordHiddenForMember' => false, +]) + +@php + $urlHelper = 'If you change the user/password/port, this could be different. This is with the default values.'; +@endphp + +
+ @if ($isPasswordHiddenForMember) + + + @else + + @if ($dbUrlPublic) + + @elseif ($showPublicUrlPlaceholder) + + @endif + @endif + + @if ($supportsSsl) +
+
+
+

SSL Configuration

+ @if ($enableSsl && $certificateValidUntil) + + @endif +
+
+ @if ($enableSsl && $certificateValidUntil) + Valid until: + @if (now()->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired + @elseif(now()->addDays(30)->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring + soon + @else + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} + @endif + + @endif +
+
+ @if ($isExited) + + @else + + @endif +
+ @if ($sslModeOptions && $enableSsl) +
+ @if ($isExited) + + @foreach ($sslModeOptions as $value => $option) + + @endforeach + + @else + + @foreach ($sslModeOptions as $value => $option) + + @endforeach + + @endif +
+ @endif +
+
+ @endif +
diff --git a/resources/views/components/deployment/configuration-diff.blade.php b/resources/views/components/deployment/configuration-diff.blade.php index ffc0cd34a..f01481057 100644 --- a/resources/views/components/deployment/configuration-diff.blade.php +++ b/resources/views/components/deployment/configuration-diff.blade.php @@ -4,9 +4,9 @@ ]) @php - $changes = data_get($diff, 'changes', []); - $count = data_get($diff, 'count', count($changes)); - $requiresBuild = data_get($diff, 'requires_build', false); + $changes = collect(data_get($diff, 'changes', []))->filter(fn ($change) => data_get($change, 'key') !== 'domains.custom_labels')->values()->all(); + $count = count($changes); + $requiresBuild = collect($changes)->contains(fn ($change) => data_get($change, 'impact') === 'build'); @endphp @if ($count > 0) @@ -21,45 +21,39 @@ 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300' => $requiresBuild, 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' => ! $requiresBuild, ])> - {{ $requiresBuild ? 'Rebuild' : 'Redeploy' }} + {{ $requiresBuild ? 'Rebuild required' : 'Redeploy required' }} @unless ($compact) -
+
@foreach (collect($changes)->groupBy('section_label') as $sectionLabel => $sectionChanges)
{{ $sectionLabel }}
-
-
-
-
Field
-
Type
-
From
-
-
To
-
-
- @foreach ($sectionChanges as $change) -
-
- {{ data_get($change, 'label') }} -
-
- {{ data_get($change, 'type') }} -
-
- {{ data_get($change, 'old_display_value') }} -
-
-
- {{ data_get($change, 'new_display_value') }} -
+
+
+
Field
+
From
+
+
To
+
+
+ @foreach ($sectionChanges as $change) +
+
+ {{ data_get($change, 'label') }}
- @endforeach -
+
+ {{ data_get($change, 'old_display_value') }} +
+
+
+ {{ data_get($change, 'new_display_value') }} +
+
+ @endforeach
diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 3b21a81d5..ecd798cc2 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -49,23 +49,32 @@ localStorage.setItem('theme', type); this.queryTheme(); }, + cycleTheme() { + const themes = ['light', 'system', 'dark']; + const currentIndex = themes.indexOf(this.theme || localStorage.getItem('theme') || 'dark'); + this.setTheme(themes[(currentIndex + 1) % themes.length]); + }, queryTheme() { const darkModePreference = window.matchMedia('(prefers-color-scheme: dark)').matches; const userSettings = localStorage.getItem('theme') || 'dark'; localStorage.setItem('theme', userSettings); + let isDark = false; if (userSettings === 'dark') { document.documentElement.classList.add('dark'); this.theme = 'dark'; + isDark = true; } else if (userSettings === 'light') { document.documentElement.classList.remove('dark'); this.theme = 'light'; } else if (darkModePreference) { this.theme = 'system'; document.documentElement.classList.add('dark'); + isDark = true; } else if (!darkModePreference) { this.theme = 'system'; document.documentElement.classList.remove('dark'); } + document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff'); }, checkZoom() { if (this.zoom === null) { @@ -92,9 +101,9 @@ } } }"> -
-
+
+ @@ -107,10 +116,10 @@ class="hover:opacity-80 transition-opacity"
-
+
-
- -
-
+
    @@ -366,9 +372,73 @@ class="{{ request()->is('settings*') ? 'menu-item-active menu-item' : 'menu-item @endif @endif
    +
  • + +
  • +
  • + + +
  • @if (isInstanceAdmin() && !isCloud()) @persist('upgrade') -
  • +
  • @endpersist @@ -420,7 +490,7 @@ class="{{ request()->is('onboarding*') ? 'menu-item-active menu-item' : 'menu-it
  • @csrf - diff --git a/resources/views/components/profile/navbar.blade.php b/resources/views/components/profile/navbar.blade.php new file mode 100644 index 000000000..4c1554ee3 --- /dev/null +++ b/resources/views/components/profile/navbar.blade.php @@ -0,0 +1,17 @@ + diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 1171c2b19..6be40f966 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -9,15 +9,19 @@ @auth diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 33968ee32..be7b928ab 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -172,7 +172,8 @@ function checkTheme() { } @auth window.Pusher = Pusher; - window.Echo = new Echo({ + const EchoConstructor = typeof Echo === 'function' ? Echo : Echo.default; + window.Echo = new EchoConstructor({ broadcaster: 'pusher', cluster: "{{ config('constants.pusher.host') }}" || window.location.hostname, key: "{{ config('constants.pusher.app_key') }}" || 'coolify', diff --git a/resources/views/livewire/destination/index.blade.php b/resources/views/livewire/destination/index.blade.php index aecd58d7a..dcf317c2b 100644 --- a/resources/views/livewire/destination/index.blade.php +++ b/resources/views/livewire/destination/index.blade.php @@ -14,34 +14,30 @@
Network endpoints to deploy your resources.
- @forelse ($servers as $server) - @forelse ($server->destinations() as $destination) - @if ($destination->getMorphClass() === 'App\Models\StandaloneDocker') - -
-
{{ $destination->name }}
-
Server: {{ $destination->server->name }}
+ @forelse ($destinations as $destination) + @if ($destination->getMorphClass() === 'App\Models\StandaloneDocker') +
+
+
{{ $destination->name }}
+
Server: {{ $destination->server->name }}
+
+
+ @endif + @if ($destination->getMorphClass() === 'App\Models\SwarmDocker') + +
+
+ {{ $destination->name }} +
-
- @endif - @if ($destination->getMorphClass() === 'App\Models\SwarmDocker') - -
-
- {{ $destination->name }} - -
-
server: {{ $destination->server->name }}
-
-
- @endif - @empty -
No destinations found.
- @endforelse +
Server: {{ $destination->server->name }}
+
+ + @endif @empty -
No servers found.
+
No destinations found.
@endforelse
diff --git a/resources/views/livewire/profile/appearance.blade.php b/resources/views/livewire/profile/appearance.blade.php new file mode 100644 index 000000000..45c2ac96c --- /dev/null +++ b/resources/views/livewire/profile/appearance.blade.php @@ -0,0 +1,119 @@ +
+ + Appearance | Coolify + + + +
+
+

Appearance

+
Choose how Coolify looks in this browser.
+
+ + + +
+
+ +
+

Width

+
Choose the maximum page width for this browser.
+
+ + +
+
+ +
+

Zoom

+
Choose interface density for this browser.
+
+ + +
+
+
+
diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index 11031b7f2..d5664fd68 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -2,8 +2,7 @@ Profile | Coolify -

Profile

-
Your user profile settings.
+

General

diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index 82ee31933..d6fcc2e4a 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -41,7 +41,7 @@ instantSave id="isPreviewDeploymentsEnabled" label="Preview Deployments" canGate="update" :canResource="$application" /> diff --git a/resources/views/livewire/project/application/configuration.blade.php b/resources/views/livewire/project/application/configuration.blade.php index 848c46ff7..6986cef05 100644 --- a/resources/views/livewire/project/application/configuration.blade.php +++ b/resources/views/livewire/project/application/configuration.blade.php @@ -27,21 +27,7 @@ @endif Servers - @if ($application->server_status == false) - - - - - - @elseif ($application->additional_servers()->exists() && str($application->status)->contains('degraded')) - - - - - - @endif + str($currentRoute)->startsWith('project.application.scheduled-tasks')]) {{ wireNavigate() }} href="{{ route('project.application.scheduled-tasks.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Scheduled Tasks diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index 350e97eb4..0f5fc3396 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -9,8 +9,12 @@ fullscreen: @entangle('fullscreen'), alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }}, rafId: null, + scrollTimeout: null, scrollDebounce: null, isScrolling: false, + destroyed: false, + morphUpdatedCleanup: null, + deploymentFinishedCleanup: null, lastTouchY: 0, showTimestamps: true, searchQuery: '', @@ -20,20 +24,32 @@ this.fullscreen = !this.fullscreen; }, scrollToBottom() { - const logsContainer = document.getElementById('logsContainer'); + if (this.destroyed) return; + const logsContainer = this.$root.querySelector('#logsContainer'); if (logsContainer) { this.isScrolling = true; logsContainer.scrollTop = logsContainer.scrollHeight; - setTimeout(() => { this.isScrolling = false; }, 50); + requestAnimationFrame(() => { this.isScrolling = false; }); + } + }, + cancelScrollLoop() { + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout); + this.scrollTimeout = null; + } + if (this.scrollDebounce) { + clearTimeout(this.scrollDebounce); + this.scrollDebounce = null; } }, disableFollow() { if (!this.alwaysScroll) return; this.alwaysScroll = false; - if (this.rafId) { - cancelAnimationFrame(this.rafId); - this.rafId = null; - } + this.cancelScrollLoop(); }, handleWheel(event) { if (this.alwaysScroll && event.deltaY < 0) { @@ -59,10 +75,11 @@ } }, handleScroll(event) { - if (this.isScrolling) return; + if (this.isScrolling || this.destroyed) return; + const el = event.target; clearTimeout(this.scrollDebounce); this.scrollDebounce = setTimeout(() => { - const el = event.target; + if (this.destroyed) return; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; if (!this.alwaysScroll && distanceFromBottom <= 10) { this.alwaysScroll = true; @@ -71,11 +88,12 @@ }, 150); }, scheduleScroll() { - if (!this.alwaysScroll) return; + if (!this.alwaysScroll || this.destroyed) return; this.rafId = requestAnimationFrame(() => { + if (!this.alwaysScroll || this.destroyed) return; this.scrollToBottom(); - if (this.alwaysScroll) { - setTimeout(() => this.scheduleScroll(), 250); + if (this.alwaysScroll && !this.destroyed) { + this.scrollTimeout = setTimeout(() => this.scheduleScroll(), 250); } }); }, @@ -84,10 +102,7 @@ if (this.alwaysScroll) { this.scheduleScroll(); } else { - if (this.rafId) { - cancelAnimationFrame(this.rafId); - this.rafId = null; - } + this.cancelScrollLoop(); } }, hasActiveLogSelection() { @@ -189,10 +204,7 @@ stopScroll() { this.scrollToBottom(); this.alwaysScroll = false; - if (this.rafId) { - cancelAnimationFrame(this.rafId); - this.rafId = null; - } + this.cancelScrollLoop(); }, init() { // Watch search query changes @@ -200,21 +212,26 @@ this.applySearch(); }); - // Apply search after Livewire updates - Livewire.hook('morph.updated', ({ el }) => { - if (el.id === 'logs') { - this.$nextTick(() => { - this.applySearch(); - if (this.alwaysScroll) { - this.scrollToBottom(); - } - }); - } + // Apply search after Livewire updates. + // Livewire.hook() returns an unregister fn; keep it for destroy(). + this.morphUpdatedCleanup = Livewire.hook('morph.updated', ({ el }) => { + if (this.destroyed) return; + if (el.id !== 'logs' || !this.$root.contains(el)) return; + this.$nextTick(() => { + if (this.destroyed) return; + this.applySearch(); + if (this.alwaysScroll) { + this.scrollToBottom(); + } + }); }); - // Stop auto-scroll when deployment finishes - Livewire.on('deploymentFinished', () => { + // Stop auto-scroll when deployment finishes. + // Livewire.on() returns an unregister fn; keep it for destroy(). + this.deploymentFinishedCleanup = Livewire.on('deploymentFinished', () => { + if (this.destroyed) return; setTimeout(() => { + if (this.destroyed) return; this.stopScroll(); }, 500); }); @@ -223,6 +240,24 @@ if (this.alwaysScroll) { this.scheduleScroll(); } + }, + destroy() { + // Runs when Alpine tears the component down (wire:navigate away). + this.destroyed = true; + this.alwaysScroll = false; + this.cancelScrollLoop(); + if (this.scrollDebounce) { + clearTimeout(this.scrollDebounce); + this.scrollDebounce = null; + } + if (typeof this.morphUpdatedCleanup === 'function') { + this.morphUpdatedCleanup(); + this.morphUpdatedCleanup = null; + } + if (typeof this.deploymentFinishedCleanup === 'function') { + this.deploymentFinishedCleanup(); + this.deploymentFinishedCleanup = null; + } } }" class="flex flex-1 min-h-0 flex-col overflow-hidden"> + @if ($application->server_status === false) + + + + + + @elseif ($application->additional_servers()->exists() && str($application->status)->contains('degraded')) + + + + + + @endif + diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php index adc57725d..12e0ca381 100644 --- a/resources/views/livewire/project/database/clickhouse/general.blade.php +++ b/resources/views/livewire/project/database/clickhouse/general.blade.php @@ -49,27 +49,8 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- @if ($isPasswordHiddenForMember) - - @else - - @endif - @if ($isPasswordHiddenForMember) - - @else - @if ($dbUrlPublic) - - @else - - @endif - @endif
+
-
-
-
-

SSL Configuration

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

Healthcheck

+ Save + @if (!$healthCheckEnabled) + + + @else + Disable Healthcheck + @endif +
+
Define how your resource's health should be checked.
+
+ @if (!$healthCheckEnabled) + +

Docker runs no healthcheck probe for this database and Coolify can no longer report a healthy/unhealthy state.

+
+ @endif + +
+ + + + +
+
+ diff --git a/resources/views/livewire/project/database/import-form.blade.php b/resources/views/livewire/project/database/import-form.blade.php new file mode 100644 index 000000000..1e384ac8d --- /dev/null +++ b/resources/views/livewire/project/database/import-form.blade.php @@ -0,0 +1,228 @@ +
+ + @script + + @endscript +
+ + + + This is a destructive action, existing data will be replaced! +
+ {{-- Restore Command Configuration --}} + @if ($resourceDbType === 'standalone-postgresql') + @if ($dumpAll) + + @else + +
+ You can add "--clean" to drop objects before creating them, avoiding + conflicts. + You can add "--verbose" to log more things. +
+ @endif +
+ +
+ @elseif ($resourceDbType === 'standalone-mysql') + @if ($dumpAll) + + @else + + @endif +
+ +
+ @elseif ($resourceDbType === 'standalone-mariadb') + @if ($dumpAll) + + @else + + @endif +
+ +
+ @endif + + {{-- Restore Type Selection Boxes --}} +

Choose Restore Method

+
+
+
+ + + +

Restore from File

+

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

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

Restore from S3

+

Download and restore a backup from S3 storage

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

Backup File

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

File Information

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

Restore from S3

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

File Information

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

Import Backup

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

Choose Restore Method

-
-
-
- - - -

Restore from File

-

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

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

Restore from S3

-

Download and restore a backup from S3 storage

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

Backup File

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

File Information

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

Restore from S3

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

File Information

-
Location: {{ $s3Path }} {{ formatBytes($s3FileSize ?? 0) }}
-
- - - Restore Database from S3 - - This will perform the following actions: -
    -
  • Download backup from S3 storage
  • -
  • Copy file into database container
  • -
  • Execute restore command
  • -
-
WARNING: This will REPLACE all existing data!
-
-
-
- @endif -
-
- @endcan - @endif - - {{-- Slide-over for activity monitor (all restore operations) --}} - - Database Restore Output - -
- -
-
-
- @else -
Database must be running to restore a backup.
- @endif +
Database must be running to restore a backup.
@endif -
\ No newline at end of file +
diff --git a/resources/views/livewire/project/database/keydb/general.blade.php b/resources/views/livewire/project/database/keydb/general.blade.php index e566e5670..78c36bca6 100644 --- a/resources/views/livewire/project/database/keydb/general.blade.php +++ b/resources/views/livewire/project/database/keydb/general.blade.php @@ -46,67 +46,8 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- @if ($isPasswordHiddenForMember) - - @else - - @endif - @if ($isPasswordHiddenForMember) - - @else - @if ($dbUrlPublic) - - @else - - @endif - @endif -
-
-
-
-

SSL Configuration

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

SSL Configuration

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

SSL Configuration

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

SSL Configuration

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

SSL Configuration

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

Proxy

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

Proxy

- - @if (data_get($database, 'is_public')) - - Proxy Logs - - - - Logs - - @endif -
-
- -
-
- - -
-
- -
- -
-
+
diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index 7572a172c..78de3479d 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -68,64 +68,8 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- @if ($isPasswordHiddenForMember) - - @else - - @endif - @if ($isPasswordHiddenForMember) - - @else - @if ($dbUrlPublic) - - @endif - @endif -
-
-
-
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
+
diff --git a/resources/views/livewire/project/database/status-info.blade.php b/resources/views/livewire/project/database/status-info.blade.php new file mode 100644 index 000000000..3e3c97c80 --- /dev/null +++ b/resources/views/livewire/project/database/status-info.blade.php @@ -0,0 +1,7 @@ +
+ +
diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php index ffe80b595..35b2ffd20 100644 --- a/resources/views/livewire/project/service/configuration.blade.php +++ b/resources/views/livewire/project/service/configuration.blade.php @@ -43,134 +43,12 @@ @endif @foreach ($applications as $application) -
str( - $application->status)->contains(['exited']), - 'border-l border-dashed border-success' => str( - $application->status)->contains(['running']), - 'border-l border-dashed border-warning' => str( - $application->status)->contains(['starting']), - 'flex gap-2 box-without-bg-without-border dark:bg-coolgray-100 bg-white dark:hover:text-neutral-300 group', - ])> - -
+ @endforeach @foreach ($databases as $database) -
str($database->status)->contains( - ['exited']), - 'border-l border-dashed border-success' => str($database->status)->contains( - ['running']), - 'border-l border-dashed border-warning' => str($database->status)->contains( - ['restarting']), - 'flex gap-2 box-without-bg-without-border dark:bg-coolgray-100 bg-white dark:hover:text-neutral-300 group', - ])> - -
+ @endforeach
@elseif ($currentRoute === 'project.service.environment-variables') diff --git a/resources/views/livewire/project/service/resource-card.blade.php b/resources/views/livewire/project/service/resource-card.blade.php new file mode 100644 index 000000000..47fb00914 --- /dev/null +++ b/resources/views/livewire/project/service/resource-card.blade.php @@ -0,0 +1,77 @@ +
str($resource->status)->contains(['exited']), + 'border-l border-dashed border-success' => str($resource->status)->contains(['running']), + 'border-l border-dashed border-warning' => str($resource->status)->contains(['starting', 'restarting']), + 'flex gap-2 box-without-bg-without-border dark:bg-coolgray-100 bg-white dark:hover:text-neutral-300 group', +])> +
+
+
+ @if ($resource->human_name) + {{ Str::headline($resource->human_name) }} + @else + {{ Str::headline($resource->name) }} + @endif + ({{ $resource->image }}) +
+ @if ($resource->configuration_required) + (configuration required) + @endif + @if ($resource->description) + {{ Str::limit($resource->description, 60) }} + @endif + @if ($isApplication && $resource->fqdn) + {{ Str::limit($resource->fqdn, 60) }} + @can('update', $service) + + + + + + + + + + + + + + @endcan + + @endif +
$isApplication, 'text-xs'])>{{ formatContainerStatus($resource->status) }}
+
+
+ @if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated)) + + Backups + + @endif + + Settings + + @if (str($resource->status)->contains('running')) + @can('update', $service) + + @endcan + @endif +
+
+
diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index 4ef77081e..230a2c22a 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -274,10 +274,13 @@
({{ $pull_request }})
@endif @if ($streamLogs) - + @endif
@endif + @if ($streamLogs) + + @endif
diff --git a/resources/views/livewire/project/shared/health-checks.blade.php b/resources/views/livewire/project/shared/health-checks.blade.php index 01f42dace..f69fd71a4 100644 --- a/resources/views/livewire/project/shared/health-checks.blade.php +++ b/resources/views/livewire/project/shared/health-checks.blade.php @@ -1,6 +1,6 @@
-

Healthchecks

+

Healthcheck

Save @if (!$healthCheckEnabled) + @if ($isTerminalConnected) + + @endif @if (!$hasShell)
@@ -18,10 +21,37 @@
@endif
+ :class="fullscreen ? 'fixed inset-0 z-[9999] m-0 h-[100dvh] w-screen max-w-none overflow-hidden rounded-none !bg-black p-0' : 'relative w-full h-full py-4 mx-auto max-h-[510px]'"> +
+
+
+
+ :class="fullscreen ? (mobileToolbarCollapsed ? 'h-[calc(100dvh-3.5rem)] mb-14 px-2 py-1 bg-black' : 'h-[calc(100dvh-6rem)] mb-[6rem] px-2 py-1 bg-black') : 'h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden px-2 py-1 rounded-sm bg-black'" + x-show="terminalActive"> +
+
+
+
+ Terminal keys + +
+
+ + + + + + +
+
@if ($resource->type() === 'application') diff --git a/resources/views/livewire/server/destinations.blade.php b/resources/views/livewire/server/destinations.blade.php index b5e8111e9..9d8a2b437 100644 --- a/resources/views/livewire/server/destinations.blade.php +++ b/resources/views/livewire/server/destinations.blade.php @@ -29,6 +29,9 @@ {{ data_get($docker, 'network') }} @endforeach + @if ($server->standaloneDockers->isEmpty() && $server->swarmDockers->isEmpty()) +
No destinations configured for this server yet.
+ @endif
@if ($networks->count() > 0)
diff --git a/resources/views/livewire/settings-dropdown.blade.php b/resources/views/livewire/settings-dropdown.blade.php index efd359098..d40d6778e 100644 --- a/resources/views/livewire/settings-dropdown.blade.php +++ b/resources/views/livewire/settings-dropdown.blade.php @@ -103,142 +103,102 @@ return new Date(b.published_at) - new Date(a.published_at); }); } -}" @click.outside="dropdownOpen = false"> - -
- + @else + +
+ + + +
+
+
+ +
+ Width
+ - @else - + + +
+ Zoom
+ - @endif - - -
- - -
- Appearance
- - - - - -
- Width
- - - - -
- Zoom
- - + +
-
+ @endif @if ($showWhatsNewModal) -
@@ -262,8 +222,7 @@ class="relative w-full h-full max-w-7xl py-6 border rounded-sm drop-shadow-sm bg
@if (isDev()) - + diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index e47fb0ae0..d52e35646 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -5,7 +5,8 @@

GitHub App

@if (data_get($github_app, 'installation_id')) - Save + Save @endif @can('delete', $github_app) @if ($applications->count() > 0) @@ -39,170 +40,187 @@ Install Repositories on GitHub @else -
-
-
- - - Sync Name - - @can('update', $github_app) - - - Rename - - - - - - Update Repositories + + +
+ + + - - @if (!isCloud()) -
- +
+ + +
- @if ($isSystemWide) - - System-wide GitHub Apps are shared across all teams on this Coolify instance. This means any team can use this GitHub App to deploy applications from your repositories. For better security and isolation, it's recommended to create team-specific GitHub Apps instead. - +
+ + @endif - @if (data_get($github_app, 'installation_id')) -
-
-
-
-

Resources

-
-
Here you can find all resources that are using this source.
-
- @if ($applications->isEmpty()) -
- No resources are currently using this GitHub App. -
- @else -
-
-
-
-
- - - - - - - - - - - @foreach ($applications->sortBy('name',SORT_NATURAL) as $resource) - - - - - - - @endforeach - -
- Project - - EnvironmentName - Type -
- {{ data_get($resource->project(), 'name') }} - - {{ data_get($resource, 'environment.name') }} - {{ $resource->name }} - - - {{ str($resource->type())->headline() }}
-
-
-
-
-
- @endif -
-
- @endif @else

GitHub App

@@ -216,89 +234,139 @@ class="" @endcan
-
+
+
@can('create', $github_app) -

Manual Installation

-
- If you want to fill the form manually, you can continue below. Only for advanced users. - - Continue - -
-

Automated Installation

-
- - - - You must complete this step before you can use this source! +
+
+
+ + + + + Recommended + +
+
+

Automated Installation

+

+ Register a GitHub App via GitHub's manifest flow. Permissions and webhooks are pre-configured. +

+
+
+ @if (!isCloud() || isDev()) + +
+ + @if ($fqdn) + + @endif + @if ($ipv4) + + @endif + @if ($ipv6) + + @endif + @if (config('app.url')) + + @endif + +
+
+ +
+ @else +
You need to register a GitHub App before using this source.
+ @endif + +
+ + +
+
+
+ + Register Now + +
+
+
+ +
+
+
+ + + + + Advanced + +
+
+

Manual Installation

+

+ Fill the GitHub App form manually. For self-hosted GitHub Enterprise or custom permission setups. +

+
+
+ + Continue + +
+
+
+ @else +
+ + You don't have permission to create new GitHub Apps. Please contact your team administrator. +
@endcan -
-
- @can('create', $github_app) - @if (!isCloud() || isDev()) -
- - @if ($fqdn) - - @endif - @if ($ipv4) - - @endif - @if ($ipv6) - - @endif - @if (config('app.url')) - - @endif - - - Register Now - -
- @else -
-

Register a GitHub App

- - Register Now - -
-
You need to register a GitHub App before using this source.
- @endif - -
- - - {{-- --}} -
- @else - - You don't have permission to create new GitHub Apps. Please contact your team administrator. - - @endcan -
+