diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 13667e829..42c6e1449 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -24,7 +24,7 @@ class StartClickhouse $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ - "echo 'Starting {$database->name}.'", + "echo 'Starting database.'", "mkdir -p $this->configuration_dir", ]; diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index c72714e1c..ea235be4e 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -26,7 +26,7 @@ class StartDragonfly $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ - "echo 'Starting {$database->name}.'", + "echo 'Starting database.'", "mkdir -p $this->configuration_dir", ]; diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index bd98258ab..010bf5884 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -27,7 +27,7 @@ class StartKeydb $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ - "echo 'Starting {$database->name}.'", + "echo 'Starting database.'", "mkdir -p $this->configuration_dir", ]; diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 696dd7ff4..2437a013e 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -24,7 +24,7 @@ class StartMariadb $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ - "echo 'Starting {$database->name}.'", + "echo 'Starting database.'", "mkdir -p $this->configuration_dir", ]; diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 26a0f82d0..a33e72c27 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -30,7 +30,7 @@ class StartMongodb } $this->commands = [ - "echo 'Starting {$database->name}.'", + "echo 'Starting database.'", "mkdir -p $this->configuration_dir", ]; diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index a3694648f..0b19b3f0c 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -24,7 +24,7 @@ class StartMysql $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ - "echo 'Starting {$database->name}.'", + "echo 'Starting database.'", "mkdir -p $this->configuration_dir", ]; diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index f5e85087f..7faa232c3 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -25,7 +25,7 @@ class StartPostgresql $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ - "echo 'Starting {$database->name}.'", + "echo 'Starting database.'", "mkdir -p $this->configuration_dir", "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d/", ]; diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 7a2d2b34d..bacf49f82 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -25,7 +25,7 @@ class StartRedis $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ - "echo 'Starting {$database->name}.'", + "echo 'Starting database.'", "mkdir -p $this->configuration_dir", ]; diff --git a/app/Console/Commands/CloudCleanupSubscriptions.php b/app/Console/Commands/CloudCleanupSubscriptions.php index 8bb420ab8..9198b003e 100644 --- a/app/Console/Commands/CloudCleanupSubscriptions.php +++ b/app/Console/Commands/CloudCleanupSubscriptions.php @@ -36,7 +36,7 @@ class CloudCleanupSubscriptions extends Command } // If the team has no subscription id and the invoice is paid, we need to reset the invoice paid status if (! (data_get($team, 'subscription.stripe_subscription_id'))) { - $this->info("Resetting invoice paid status for team {$team->id} {$team->name}"); + $this->info("Resetting invoice paid status for team {$team->id}"); $team->subscription->update([ 'stripe_invoice_paid' => false, @@ -61,9 +61,9 @@ class CloudCleanupSubscriptions extends Command $this->info('Subscription id: '.data_get($team, 'subscription.stripe_subscription_id')); $confirm = $this->confirm('Do you want to cancel the subscription?', true); if (! $confirm) { - $this->info("Skipping team {$team->id} {$team->name}"); + $this->info("Skipping team {$team->id}"); } else { - $this->info("Cancelling subscription for team {$team->id} {$team->name}"); + $this->info("Cancelling subscription for team {$team->id}"); $team->subscription->update([ 'stripe_invoice_paid' => false, 'stripe_trial_already_ended' => false, diff --git a/app/Livewire/Project/Application/Configuration.php b/app/Livewire/Project/Application/Configuration.php index cce3bdd39..5261a0800 100644 --- a/app/Livewire/Project/Application/Configuration.php +++ b/app/Livewire/Project/Application/Configuration.php @@ -16,24 +16,26 @@ class Configuration extends Component public function mount() { - $this->application = Application::query() - ->whereHas('environment.project', function ($query) { - $query->where('team_id', currentTeam()->id) - ->where('uuid', request()->route('project_uuid')); - }) - ->whereHas('environment', function ($query) { - $query->where('name', request()->route('environment_name')); - }) + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', request()->route('project_uuid')) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'name', 'project_id') + ->where('name', request()->route('environment_name')) + ->firstOrFail(); + $application = $environment->applications() + ->with(['destination']) ->where('uuid', request()->route('application_uuid')) - ->with(['destination' => function ($query) { - $query->select('id', 'server_id'); - }]) ->firstOrFail(); - if ($this->application->destination && $this->application->destination->server_id) { + $this->application = $application; + if ($application->destination && $application->destination->server) { + $mainServer = $application->destination->server; $this->servers = Server::ownedByCurrentTeam() ->select('id', 'name') - ->where('id', '!=', $this->application->destination->server_id) + ->where('id', '!=', $mainServer->id) ->get(); } else { $this->servers = collect(); diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index 621ab1bac..d12d8e26a 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -168,18 +168,42 @@ class ExecuteContainerCommand extends Component return; } try { + // Validate container name format + if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $this->selected_container)) { + throw new \InvalidArgumentException('Invalid container name format'); + } + + // Verify container exists in our allowed list $container = collect($this->containers)->firstWhere('container.Names', $this->selected_container); if (is_null($container)) { throw new \RuntimeException('Container not found.'); } - $server = data_get($this->container, 'server'); + + // Verify server ownership and status + $server = data_get($container, 'server'); + if (! $server || ! $server instanceof Server) { + throw new \RuntimeException('Invalid server configuration.'); + } if ($server->isForceDisabled()) { throw new \RuntimeException('Server is disabled.'); } + + // Additional ownership verification based on resource type + $resourceServer = match ($this->type) { + 'application' => $this->resource->destination->server, + 'database' => $this->resource->destination->server, + 'service' => $this->resource->server, + default => throw new \RuntimeException('Invalid resource type.') + }; + + if ($server->id !== $resourceServer->id && ! $this->resource->additional_servers->contains('id', $server->id)) { + throw new \RuntimeException('Server ownership verification failed.'); + } + $this->dispatch( 'send-terminal-command', - isset($container), + true, data_get($container, 'container.Names'), data_get($container, 'server.uuid') ); diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index 5af8f057e..d8f101277 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -29,11 +29,20 @@ class Terminal extends Component $server = Server::ownedByCurrentTeam()->whereUuid($serverUuid)->firstOrFail(); if ($isContainer) { + // Validate container identifier format (alphanumeric, dashes, and underscores only) + if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $identifier)) { + throw new \InvalidArgumentException('Invalid container identifier format'); + } + + // Verify container exists and belongs to the user's team $status = getContainerStatus($server, $identifier); if ($status !== 'running') { return; } - $command = SshMultiplexingHelper::generateSshCommand($server, "docker exec -it {$identifier} sh -c 'PATH=\$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && if [ -f ~/.profile ]; then . ~/.profile; fi && if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); + + // Escape the identifier for shell usage + $escapedIdentifier = escapeshellarg($identifier); + $command = SshMultiplexingHelper::generateSshCommand($server, "docker exec -it {$escapedIdentifier} sh -c 'PATH=\$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && if [ -f ~/.profile ]; then . ~/.profile; fi && if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); } else { $command = SshMultiplexingHelper::generateSshCommand($server, 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && if [ -f ~/.profile ]; then . ~/.profile; fi && if [ -n "$SHELL" ]; then exec $SHELL; else sh; fi'); } diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index a5544489d..ac5211c1b 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -5,7 +5,7 @@ namespace App\Livewire\Server; use App\Actions\Server\StartSentinel; use App\Actions\Server\StopSentinel; use App\Models\Server; -use Livewire\Attributes\Locked; +use Livewire\Attributes\Computed; use Livewire\Attributes\Validate; use Livewire\Component; @@ -79,9 +79,6 @@ class Show extends Component #[Validate(['required'])] public string $serverTimezone; - #[Locked] - public array $timezones; - public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -96,13 +93,21 @@ class Show extends Component { try { $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); - $this->timezones = collect(timezone_identifiers_list())->sort()->values()->toArray(); $this->syncData(); } catch (\Throwable $e) { return handleError($e, $this); } } + #[Computed] + public function timezones(): array + { + return collect(timezone_identifiers_list()) + ->sort() + ->values() + ->toArray(); + } + public function syncData(bool $toModel = false) { if ($toModel) { diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 31dd13c52..c1be35ced 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -7,7 +7,7 @@ use App\Models\InstanceSettings; use App\Models\Server; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; -use Livewire\Attributes\Locked; +use Livewire\Attributes\Computed; use Livewire\Attributes\Validate; use Livewire\Component; @@ -17,9 +17,6 @@ class Index extends Component protected Server $server; - #[Locked] - public $timezones; - #[Validate('boolean')] public bool $is_auto_update_enabled; @@ -101,12 +98,20 @@ class Index extends Component $this->is_api_enabled = $this->settings->is_api_enabled; $this->auto_update_frequency = $this->settings->auto_update_frequency; $this->update_check_frequency = $this->settings->update_check_frequency; - $this->timezones = collect(timezone_identifiers_list())->sort()->values()->toArray(); $this->instance_timezone = $this->settings->instance_timezone; $this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation; } } + #[Computed] + public function timezones(): array + { + return collect(timezone_identifiers_list()) + ->sort() + ->values() + ->toArray(); + } + public function instantSave($isSave = true) { $this->validate(); diff --git a/app/Models/Application.php b/app/Models/Application.php index c284528f1..a68c1d54a 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\ApplicationDeploymentStatus; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Process\InvokedProcess; @@ -104,7 +105,7 @@ use Visus\Cuid2\Cuid2; class Application extends BaseModel { - use SoftDeletes; + use HasFactory, SoftDeletes; private static $parserVersion = '4'; diff --git a/app/Models/Server.php b/app/Models/Server.php index 83b91b254..e0a66c58b 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -11,6 +11,7 @@ use App\Notifications\Server\Reachable; use App\Notifications\Server\Unreachable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -48,7 +49,7 @@ use Symfony\Component\Yaml\Yaml; class Server extends BaseModel { - use SchemalessAttributesTrait, SoftDeletes; + use HasFactory, SchemalessAttributesTrait, SoftDeletes; public static $batch_counter = 0; diff --git a/app/Models/Team.php b/app/Models/Team.php index 6ba044349..e21aa3a25 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -127,13 +127,6 @@ class Team extends Model implements SendsDiscord, SendsEmail ]; } - public function name(): Attribute - { - return new Attribute( - get: fn () => sanitize_string($this->getRawOriginal('name')), - ); - } - public function getRecepients($notification) { $recipients = data_get($notification, 'emails', null); diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index 303fcab8e..b568e090c 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -46,7 +46,7 @@ const SPECIFIC_SERVICES = [ // Based on /etc/os-release const SUPPORTED_OS = [ - 'ubuntu debian raspbian', + 'ubuntu debian raspbian pop', 'centos fedora rhel ol rocky amzn almalinux', 'sles opensuse-leap opensuse-tumbleweed', 'arch', diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index d64b5ab6e..a3ef93dfc 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -90,8 +90,11 @@ function metrics_dir(): string return base_configuration_dir().'/metrics'; } -function sanitize_string(string $input): string +function sanitize_string(?string $input = null): ?string { + if (is_null($input)) { + return null; + } // Remove any HTML/PHP tags $sanitized = strip_tags($input); diff --git a/config/constants.php b/config/constants.php index c947635be..4923cfc15 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.374', + 'version' => '4.0.0-beta.375', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), diff --git a/config/database.php b/config/database.php index f48a68082..6f4acbfd2 100644 --- a/config/database.php +++ b/config/database.php @@ -49,6 +49,22 @@ return [ 'search_path' => 'public', 'sslmode' => 'prefer', ], + + 'testing' => [ + 'driver' => 'pgsql', + 'url' => env('DATABASE_TEST_URL'), + 'host' => env('DB_TEST_HOST', 'postgres'), + 'port' => env('DB_TEST_PORT', '5432'), + 'database' => env('DB_TEST_DATABASE', 'coolify_test'), + 'username' => env('DB_TEST_USERNAME', 'coolify'), + 'password' => env('DB_TEST_PASSWORD', 'password'), + 'charset' => 'utf8', + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], + ], /* diff --git a/database/factories/ApplicationFactory.php b/database/factories/ApplicationFactory.php new file mode 100644 index 000000000..ded507c56 --- /dev/null +++ b/database/factories/ApplicationFactory.php @@ -0,0 +1,22 @@ + fake()->unique()->name(), + 'destination_id' => 1, + 'git_repository' => fake()->url(), + 'git_branch' => fake()->word(), + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => 1, + 'destination_id' => 1, + ]; + } +} diff --git a/database/factories/ServerFactory.php b/database/factories/ServerFactory.php new file mode 100644 index 000000000..29546bf56 --- /dev/null +++ b/database/factories/ServerFactory.php @@ -0,0 +1,17 @@ + fake()->unique()->name(), + 'ip' => fake()->unique()->ipv4(), + 'private_key_id' => 1, + ]; + } +} diff --git a/phpunit.xml b/phpunit.xml index 45cb69439..f1c2be92d 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -13,8 +13,8 @@ - - + + diff --git a/public/svgs/plex.svg b/public/svgs/plex.svg new file mode 100644 index 000000000..872b135cf --- /dev/null +++ b/public/svgs/plex.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php index c090037d5..acd3b8bdf 100644 --- a/resources/views/components/modal-input.blade.php +++ b/resources/views/components/modal-input.blade.php @@ -7,6 +7,7 @@ 'action' => 'delete', 'content' => null, 'closeOutside' => true, + 'minWidth' => '36rem', ])
@@ -40,7 +41,7 @@ x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100" x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95" - class="relative w-full py-6 border rounded drop-shadow min-w-full lg:min-w-[36rem] max-w-fit bg-white border-neutral-200 dark:bg-base px-6 dark:border-coolgray-300"> + class="relative w-full py-6 border rounded drop-shadow min-w-full lg:min-w-[{{ $minWidth }}] max-w-fit bg-white border-neutral-200 dark:bg-base px-6 dark:border-coolgray-300">

{{ $title }}