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 @@