Merge branch 'coollabsio:v4.x' into main

This commit is contained in:
tiennm99 authored and GitHub committed 2026-06-18 09:57:35 +07:00
commit 44249b8fb3
1139 files changed
+93029 -15243

No files matched your search

+502 -108
View File
@@ -4,6 +4,9 @@ namespace App\Models;
use App\Enums\ApplicationDeploymentStatus;
use App\Services\ConfigurationGenerator;
use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot;
use App\Services\DeploymentConfiguration\ConfigurationDiff;
use App\Services\DeploymentConfiguration\ConfigurationDiffer;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasConfiguration;
use App\Traits\HasMetrics;
@@ -39,7 +42,7 @@ use Visus\Cuid2\Cuid2;
'git_full_url' => ['type' => 'string', 'nullable' => true, 'description' => 'Git full URL.'],
'docker_registry_image_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image name.'],
'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image tag.'],
'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose']],
'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose']],
'static_image' => ['type' => 'string', 'description' => 'Static image used when static site is deployed.'],
'install_command' => ['type' => 'string', 'description' => 'Install command.'],
'build_command' => ['type' => 'string', 'description' => 'Build command.'],
@@ -61,6 +64,8 @@ use Visus\Cuid2\Cuid2;
'health_check_timeout' => ['type' => 'integer', 'description' => 'Health check timeout in seconds.'],
'health_check_retries' => ['type' => 'integer', 'description' => 'Health check retries count.'],
'health_check_start_period' => ['type' => 'integer', 'description' => 'Health check start period in seconds.'],
'health_check_type' => ['type' => 'string', 'description' => 'Health check type: http or cmd.', 'enum' => ['http', 'cmd']],
'health_check_command' => ['type' => 'string', 'nullable' => true, 'description' => 'Health check command for CMD type.'],
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit.'],
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit.'],
'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness.'],
@@ -116,18 +121,126 @@ class Application extends BaseModel
private static $parserVersion = '5';
protected $guarded = [];
protected $fillable = [
'name',
'description',
'fqdn',
'git_repository',
'git_branch',
'git_commit_sha',
'git_full_url',
'docker_registry_image_name',
'docker_registry_image_tag',
'build_pack',
'static_image',
'install_command',
'build_command',
'start_command',
'ports_exposes',
'ports_mappings',
'base_directory',
'publish_directory',
'health_check_enabled',
'health_check_path',
'health_check_port',
'health_check_host',
'health_check_method',
'health_check_return_code',
'health_check_scheme',
'health_check_response_text',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
'health_check_type',
'health_check_command',
'limits_memory',
'limits_memory_swap',
'limits_memory_swappiness',
'limits_memory_reservation',
'limits_cpus',
'limits_cpuset',
'limits_cpu_shares',
'status',
'preview_url_template',
'dockerfile',
'dockerfile_location',
'dockerfile_target_build',
'custom_labels',
'custom_docker_run_options',
'post_deployment_command',
'post_deployment_command_container',
'pre_deployment_command',
'pre_deployment_command_container',
'manual_webhook_secret_github',
'manual_webhook_secret_gitlab',
'manual_webhook_secret_bitbucket',
'manual_webhook_secret_gitea',
'docker_compose_location',
'docker_compose_pr_location',
'docker_compose',
'docker_compose_pr',
'docker_compose_raw',
'docker_compose_pr_raw',
'docker_compose_domains',
'docker_compose_custom_start_command',
'docker_compose_custom_build_command',
'swarm_replicas',
'swarm_placement_constraints',
'watch_paths',
'redirect',
'compose_parsing_version',
'custom_nginx_configuration',
'custom_network_aliases',
'custom_healthcheck_found',
'nixpkgsarchive',
'is_http_basic_auth_enabled',
'http_basic_auth_username',
'http_basic_auth_password',
'connect_to_docker_network',
'force_domain_override',
'is_container_label_escape_enabled',
'use_build_server',
'config_hash',
'last_online_at',
'restart_count',
'max_restart_count',
'last_restart_at',
'last_restart_type',
'uuid',
'environment_id',
'destination_id',
'destination_type',
'source_id',
'source_type',
'repository_project_id',
'private_key_id',
];
protected $appends = ['server_status'];
protected $casts = [
'http_basic_auth_password' => 'encrypted',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
];
protected function casts(): array
{
return [
'http_basic_auth_password' => 'encrypted',
'manual_webhook_secret_github' => 'encrypted',
'manual_webhook_secret_gitlab' => 'encrypted',
'manual_webhook_secret_bitbucket' => 'encrypted',
'manual_webhook_secret_gitea' => 'encrypted',
'restart_count' => 'integer',
'max_restart_count' => 'integer',
'last_restart_at' => 'datetime',
];
}
protected static function booted()
{
static::creating(function ($application) {
$application->manual_webhook_secret_github ??= Str::random(40);
$application->manual_webhook_secret_gitlab ??= Str::random(40);
$application->manual_webhook_secret_bitbucket ??= Str::random(40);
$application->manual_webhook_secret_gitea ??= Str::random(40);
});
static::addGlobalScope('withRelations', function ($builder) {
$builder->withCount([
'additional_servers',
@@ -175,7 +288,7 @@ class Application extends BaseModel
}
}
if (count($payload) > 0) {
$application->forceFill($payload);
$application->fill($payload);
}
// Buildpack switching cleanup logic
@@ -388,7 +501,7 @@ class Application extends BaseModel
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
}
@@ -459,6 +572,15 @@ class Application extends BaseModel
return null;
}
public function stoppedAfterRestartLimit(): bool
{
return str($this->status)->startsWith('exited')
&& ($this->restart_count ?? 0) > 0
&& ($this->max_restart_count ?? 0) > 0
&& $this->restart_count >= $this->max_restart_count
&& $this->last_restart_type === 'crash';
}
public function taskLink($task_uuid)
{
if (data_get($this, 'environment.project.uuid')) {
@@ -612,14 +734,14 @@ class Application extends BaseModel
return Attribute::make(
set: function ($value) {
if (is_null($value) || $value === '') {
return '/Dockerfile';
} else {
if ($value !== '/') {
return Str::start(Str::replaceEnd('/', '', $value), '/');
}
return Str::start($value, '/');
return $this->build_pack === 'dockerfile' ? '/Dockerfile' : null;
}
if ($value !== '/') {
return Str::start(Str::replaceEnd('/', '', $value), '/');
}
return Str::start($value, '/');
}
);
}
@@ -778,8 +900,8 @@ class Application extends BaseModel
public function customNginxConfiguration(): Attribute
{
return Attribute::make(
set: fn ($value) => base64_encode($value),
get: fn ($value) => base64_decode($value),
set: fn ($value) => is_null($value) ? null : base64_encode($value),
get: fn ($value) => is_null($value) ? null : base64_decode($value),
);
}
@@ -852,7 +974,7 @@ class Application extends BaseModel
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', false)
->where('key', 'not like', 'NIXPACKS_%');
->withoutBuildpackControlVariables();
}
public function nixpacks_environment_variables()
@@ -862,6 +984,13 @@ class Application extends BaseModel
->where('key', 'like', 'NIXPACKS_%');
}
public function railpack_environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', false)
->where('key', 'like', 'RAILPACK_%');
}
public function environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
@@ -880,7 +1009,7 @@ class Application extends BaseModel
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', true)
->where('key', 'not like', 'NIXPACKS_%');
->withoutBuildpackControlVariables();
}
public function nixpacks_environment_variables_preview()
@@ -890,6 +1019,13 @@ class Application extends BaseModel
->where('key', 'like', 'NIXPACKS_%');
}
public function railpack_environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', true)
->where('key', 'like', 'RAILPACK_%');
}
public function scheduled_tasks(): HasMany
{
return $this->hasMany(ScheduledTask::class)->orderBy('name', 'asc');
@@ -937,7 +1073,7 @@ class Application extends BaseModel
public function get_last_successful_deployment()
{
return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first();
return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED->value)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first();
}
public function get_last_days_deployments()
@@ -987,22 +1123,29 @@ class Application extends BaseModel
public function deploymentType()
{
if (isDev() && data_get($this, 'private_key_id') === 0) {
$privateKeyId = data_get($this, 'private_key_id');
// Real private key (id > 0) always takes precedence
if ($privateKeyId !== null && $privateKeyId > 0) {
return 'deploy_key';
}
if (data_get($this, 'private_key_id')) {
return 'deploy_key';
} elseif (data_get($this, 'source')) {
// GitHub/GitLab App source
if (data_get($this, 'source')) {
return 'source';
} else {
return 'other';
}
throw new \Exception('No deployment type found');
// Localhost key (id = 0) when no source is configured
if ($privateKeyId === 0) {
return 'deploy_key';
}
return 'other';
}
public function could_set_build_commands(): bool
{
if ($this->build_pack === 'nixpacks') {
if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
return true;
}
@@ -1042,32 +1185,94 @@ class Application extends BaseModel
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets.$this->settings->inject_build_args_to_dockerfile.$this->settings->include_source_commit_in_build);
$configurationDiff = $this->pendingDeploymentConfigurationDiff();
if ($save) {
$this->markDeploymentConfigurationApplied();
}
return $configurationDiff->isChanged();
}
public function pendingDeploymentConfigurationDiff(): ConfigurationDiff
{
$currentSnapshot = $this->deploymentConfigurationSnapshot();
$lastDeployment = $this->get_last_successful_deployment();
$previousSnapshot = $lastDeployment?->configuration_snapshot;
if (! $previousSnapshot) {
$oldConfigHash = data_get($this, 'config_hash');
$hasLegacyChange = $oldConfigHash === null || $oldConfigHash !== $this->legacyConfigurationHash();
if (! $hasLegacyChange) {
return ConfigurationDiff::unchanged();
}
$previousSnapshot = [];
}
return app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot);
}
public function hasPendingDeploymentConfigurationChanges(): bool
{
return $this->pendingDeploymentConfigurationDiff()->isChanged();
}
public function deploymentConfigurationSnapshot(): array
{
return (new ApplicationConfigurationSnapshot($this))->toArray();
}
public function deploymentConfigurationHash(): string
{
return ApplicationConfigurationSnapshot::hashSnapshot($this->deploymentConfigurationSnapshot());
}
public function markDeploymentConfigurationApplied(?ApplicationDeploymentQueue $deployment = null): void
{
$this->refresh();
if (! $deployment) {
$this->forceFill(['config_hash' => $this->legacyConfigurationHash()])->save();
return;
}
$snapshot = $this->deploymentConfigurationSnapshot();
$hash = ApplicationConfigurationSnapshot::hashSnapshot($snapshot);
$previousDeployment = ApplicationDeploymentQueue::query()
->where('application_id', $this->id)
->where('status', ApplicationDeploymentStatus::FINISHED->value)
->where('pull_request_id', $deployment->pull_request_id ?? 0)
->where('id', '!=', $deployment->id)
->whereNotNull('configuration_snapshot')
->latest()
->first();
$deployment->update([
'configuration_hash' => $hash,
'configuration_snapshot' => $snapshot,
'configuration_diff' => $previousDeployment?->configuration_snapshot
? app(ConfigurationDiffer::class)->diff($previousDeployment->configuration_snapshot, $snapshot)->toArray()
: null,
]);
$this->forceFill(['config_hash' => $hash])->save();
}
private function legacyConfigurationHash(): string
{
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build);
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
} else {
$newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
$newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
}
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
if ($oldConfigHash === null) {
if ($save) {
$this->config_hash = $newConfigHash;
$this->save();
}
return true;
}
if ($oldConfigHash === $newConfigHash) {
return false;
} else {
if ($save) {
$this->config_hash = $newConfigHash;
$this->save();
}
return true;
}
return md5($newConfigHash);
}
public function customRepository()
@@ -1085,19 +1290,32 @@ class Application extends BaseModel
return application_configuration_dir()."/{$this->uuid}";
}
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false)
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';
if ($this->git_commit_sha !== 'HEAD') {
$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.
$commitToUse = $commit ?? $this->git_commit_sha;
if ($commitToUse !== 'HEAD') {
$escapedCommit = escapeshellarg($commitToUse);
// 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} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$this->git_commit_sha} && git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/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} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/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) {
@@ -1108,10 +1326,10 @@ class Application extends BaseModel
}
// Add shallow submodules flag if shallow clone is enabled
$submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : '';
$git_clone_command = "{$git_clone_command} git submodule sync && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" 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} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull";
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} lfs pull";
}
return $git_clone_command;
@@ -1127,7 +1345,7 @@ class Application extends BaseModel
'is_accessible' => true,
'error' => null,
];
} catch (\RuntimeException $ex) {
} catch (RuntimeException $ex) {
return [
'is_accessible' => false,
'error' => $ex->getMessage(),
@@ -1140,6 +1358,7 @@ class Application extends BaseModel
$branch = $this->git_branch;
['repository' => $customRepository, 'port' => $customPort] = $this->customRepository();
$commands = collect([]);
$customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deployment_uuid}";
$base_command = 'git ls-remote';
if ($this->deploymentType() === 'source') {
@@ -1156,14 +1375,15 @@ class Application extends BaseModel
$base_command = "{$base_command} {$escapedRepoUrl}";
} else {
$github_access_token = generateGithubInstallationToken($this->source);
$encodedToken = rawurlencode($github_access_token);
if ($exec_in_docker) {
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
$repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git";
$escapedRepoUrl = escapeshellarg($repoUrl);
$base_command = "{$base_command} {$escapedRepoUrl}";
$fullRepoUrl = $repoUrl;
} else {
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
$repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}";
$escapedRepoUrl = escapeshellarg($repoUrl);
$base_command = "{$base_command} {$escapedRepoUrl}";
$fullRepoUrl = $repoUrl;
@@ -1182,6 +1402,63 @@ class Application extends BaseModel
'fullRepoUrl' => $fullRepoUrl,
];
}
if ($this->source->getMorphClass() === GitlabApp::class) {
$gitlabSource = $this->source;
$private_key = data_get($gitlabSource, 'privateKey.private_key');
if ($private_key) {
$fullRepoUrl = $customRepository;
$private_key = base64_encode($private_key);
$gitlabPort = $gitlabSource->custom_port ?? 22;
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$base_command} '{$escapedCustomRepository}'";
if ($exec_in_docker) {
$commands = collect([
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
]);
} else {
$commands = collect([
"trap 'rm -f {$customSshKeyLocation}' EXIT",
'mkdir -p /root/.ssh',
"echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
"chmod 600 {$customSshKeyLocation}",
]);
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $base_command));
} else {
$commands->push($base_command);
}
return [
'commands' => $commands->implode(' && '),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
}
// GitLab source without private key — use URL as-is (supports user-embedded basic auth)
$fullRepoUrl = $customRepository;
$escapedCustomRepository = escapeshellarg($customRepository);
$base_command = "{$base_command} {$escapedCustomRepository}";
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $base_command));
} else {
$commands->push($base_command);
}
return [
'commands' => $commands->implode(' && '),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
}
}
if ($this->deploymentType() === 'deploy_key') {
@@ -1194,19 +1471,20 @@ class Application extends BaseModel
// When used with executeInDocker (which uses bash -c '...'), we need to escape for bash context
// Replace ' with '\'' to safely escape within single-quoted bash strings
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
$base_command = "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\" {$base_command} '{$escapedCustomRepository}'";
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$base_command} '{$escapedCustomRepository}'";
if ($exec_in_docker) {
$commands = collect([
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
]);
} else {
$commands = collect([
"trap 'rm -f {$customSshKeyLocation}' EXIT",
'mkdir -p /root/.ssh',
"echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null",
'chmod 600 /root/.ssh/id_rsa',
"echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
"chmod 600 {$customSshKeyLocation}",
]);
}
@@ -1242,11 +1520,34 @@ class Application extends BaseModel
}
}
private function withGitHttpTransportConfig(?string $gitConfigOptions = null): string
{
return trim(($gitConfigOptions ? "{$gitConfigOptions} " : '').'-c http.version=HTTP/1.1');
}
private function isHttpGitRepository(string $repository): bool
{
return str_starts_with($repository, 'https://') || str_starts_with($repository, 'http://');
}
private function applyGitConfigOptionsToCloneCommand(string $gitCloneCommand, string $gitConfigOptions): string
{
$configuredCommand = preg_replace(
"/^git(?:\s+-c\s+(?:'[^']*'|\S+))*\s+clone\b/",
"git {$gitConfigOptions} clone",
$gitCloneCommand,
1
);
return $configuredCommand ?: $gitCloneCommand;
}
public function generateGitImportCommands(string $deployment_uuid, int $pull_request_id = 0, ?string $git_type = null, bool $exec_in_docker = true, bool $only_checkout = false, ?string $custom_base_dir = null, ?string $commit = null)
{
$branch = $this->git_branch;
['repository' => $customRepository, 'port' => $customPort] = $this->customRepository();
$baseDir = $custom_base_dir ?? $this->generateBaseDir($deployment_uuid);
$customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deployment_uuid}";
// Escape shell arguments for safety to prevent command injection
$escapedBranch = escapeshellarg($branch);
@@ -1279,13 +1580,15 @@ class Application extends BaseModel
$source_html_url_host = $url['host'];
$source_html_url_scheme = $url['scheme'];
if ($this->source->getMorphClass() === \App\Models\GithubApp::class) {
if ($this->source->getMorphClass() === GithubApp::class) {
if ($this->source->is_public) {
$fullRepoUrl = "{$this->source->html_url}/{$customRepository}";
$escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}");
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
$gitConfigOptions = $this->withGitHttpTransportConfig();
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
if (! $only_checkout) {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true);
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
@@ -1294,19 +1597,27 @@ class Application extends BaseModel
}
} 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}/");
$gitConfigOptions = $this->withGitHttpTransportConfig($gitConfigOption);
$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:$github_access_token@$source_html_url_host/{$customRepository}.git";
$repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git";
$escapedRepoUrl = escapeshellarg($repoUrl);
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
$fullRepoUrl = $repoUrl;
} else {
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
$repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}";
$escapedRepoUrl = escapeshellarg($repoUrl);
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
$fullRepoUrl = $repoUrl;
}
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
if (! $only_checkout) {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false);
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOptions);
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
@@ -1317,12 +1628,13 @@ class Application extends BaseModel
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: $gitConfigOptions ?? null);
$gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git';
$escapedPrBranch = escapeshellarg($branch);
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command"));
$commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command"));
} else {
$commands->push("cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command");
$commands->push("cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command");
}
}
@@ -1332,6 +1644,84 @@ class Application extends BaseModel
'fullRepoUrl' => $fullRepoUrl,
];
}
if ($this->source->getMorphClass() === GitlabApp::class) {
$gitlabSource = $this->source;
$private_key = data_get($gitlabSource, 'privateKey.private_key');
if ($private_key) {
$fullRepoUrl = $customRepository;
$private_key = base64_encode($private_key);
$gitlabPort = $gitlabSource->custom_port ?? 22;
$escapedCustomRepository = escapeshellarg($customRepository);
$gitlabSshCommand = "ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes";
$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, gitSshCommand: $gitlabSshCommand);
}
if ($exec_in_docker) {
$commands = collect([
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
]);
} else {
$commands = collect([
"trap 'rm -f {$customSshKeyLocation}' EXIT",
'mkdir -p /root/.ssh',
"echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
"chmod 600 {$customSshKeyLocation}",
]);
}
if ($pull_request_id !== 0) {
$branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name";
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} && {$gitlabGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $gitlabSshCommand);
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
} else {
$commands->push($git_clone_command);
}
return [
'commands' => $commands->implode(' && '),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
}
// GitLab source without private key — use URL as-is (supports user-embedded basic auth)
$fullRepoUrl = $customRepository;
$escapedCustomRepository = escapeshellarg($customRepository);
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
$gitConfigOptions = $this->isHttpGitRepository($customRepository) ? $this->withGitHttpTransportConfig() : null;
if ($gitConfigOptions) {
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
}
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
} else {
$commands->push($git_clone_command);
}
return [
'commands' => $commands->implode(' && '),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
}
}
if ($this->deploymentType() === 'deploy_key') {
$fullRepoUrl = $customRepository;
@@ -1341,23 +1731,26 @@ class Application extends BaseModel
}
$private_key = base64_encode($private_key);
$escapedCustomRepository = escapeshellarg($customRepository);
$git_clone_command_base = "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} {$escapedCustomRepository} {$escapedBaseDir}";
$deployKeySshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes";
$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);
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand);
}
if ($exec_in_docker) {
$commands = collect([
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
]);
} else {
$commands = collect([
"trap 'rm -f {$customSshKeyLocation}' EXIT",
'mkdir -p /root/.ssh',
"echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null",
'chmod 600 /root/.ssh/id_rsa',
"echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
"chmod 600 {$customSshKeyLocation}",
]);
}
if ($pull_request_id !== 0) {
@@ -1368,7 +1761,7 @@ class Application extends BaseModel
} 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} && {$deployKeyGitSshCommand} 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) {
@@ -1376,14 +1769,14 @@ class Application extends BaseModel
} 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} && {$deployKeyGitSshCommand} 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} && {$deployKeyGitSshCommand} ".$this->buildGitCheckoutCommand($commit, $deployKeySshCommand);
}
}
@@ -1403,9 +1796,15 @@ class Application extends BaseModel
$fullRepoUrl = $customRepository;
$escapedCustomRepository = escapeshellarg($customRepository);
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true);
$gitConfigOptions = $this->isHttpGitRepository($customRepository) ? $this->withGitHttpTransportConfig() : null;
if ($gitConfigOptions) {
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
}
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
$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) {
$gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git';
if ($git_type === 'gitlab') {
$branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) {
@@ -1413,7 +1812,7 @@ class Application extends BaseModel
} 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}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions);
} elseif ($git_type === 'github' || $git_type === 'gitea') {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) {
@@ -1421,14 +1820,14 @@ class Application extends BaseModel
} 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}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions);
} 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, $gitConfigOptions);
}
}
@@ -1451,7 +1850,7 @@ class Application extends BaseModel
try {
$yaml = Yaml::parse($this->docker_compose_raw);
} catch (\Exception $e) {
throw new \RuntimeException($e->getMessage());
throw new RuntimeException($e->getMessage());
}
$services = data_get($yaml, 'services');
@@ -1527,13 +1926,14 @@ class Application extends BaseModel
return;
}
$uuid = new Cuid2;
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: '.');
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: 'checkout');
$cloneCommand = str_replace(' clone ', ' clone --quiet ', $cloneCommand);
$workdir = rtrim($this->base_directory, '/');
$composeFile = $this->docker_compose_location;
$fileList = collect([".$workdir$composeFile"]);
$gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid);
if (! $gitRemoteStatus['is_accessible']) {
throw new \RuntimeException("Failed to read Git source:\n\n{$gitRemoteStatus['error']}");
throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.');
}
$getGitVersion = instant_remote_process(['git --version'], $this->destination->server, false);
$gitVersion = str($getGitVersion)->explode(' ')->last();
@@ -1557,6 +1957,7 @@ class Application extends BaseModel
"mkdir -p /tmp/{$uuid}",
"cd /tmp/{$uuid}",
$cloneCommand,
'cd checkout',
'git sparse-checkout init',
"git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD',
@@ -1568,6 +1969,7 @@ class Application extends BaseModel
"mkdir -p /tmp/{$uuid}",
"cd /tmp/{$uuid}",
$cloneCommand,
'cd checkout',
'git sparse-checkout init --cone',
"git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD',
@@ -1583,15 +1985,15 @@ class Application extends BaseModel
$this->save();
if (str($e->getMessage())->contains('No such file')) {
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
throw new RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
if (str($e->getMessage())->contains('fatal: repository') && str($e->getMessage())->contains('does not exist')) {
if ($this->deploymentType() === 'deploy_key') {
throw new \RuntimeException('Your deploy key does not have access to the repository. Please check your deploy key and try again.');
throw new RuntimeException('Your deploy key does not have access to the repository. Please check your deploy key and try again.');
}
throw new \RuntimeException('Repository does not exist. Please check your repository URL and try again.');
throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.');
}
throw new \RuntimeException($e->getMessage());
throw new RuntimeException('Failed to read the Docker Compose file from the repository.');
} finally {
// Cleanup only - restoration happens in catch block
$commands = collect([
@@ -1644,7 +2046,7 @@ class Application extends BaseModel
$this->base_directory = $initialBaseDirectory;
$this->save();
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
throw new RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
}
@@ -1677,12 +2079,15 @@ class Application extends BaseModel
);
}
protected function buildGitCheckoutCommand($target): string
protected function buildGitCheckoutCommand($target, ?string $gitSshCommand = null, ?string $gitConfigOptions = null): string
{
$command = "git checkout $target";
$escapedTarget = escapeshellarg($target);
$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;
@@ -1959,17 +2364,6 @@ class Application extends BaseModel
}
}
public static function getDomainsByUuid(string $uuid): array
{
$application = self::where('uuid', $uuid)->first();
if ($application) {
return $application->fqdns;
}
return [];
}
public function getLimits(): array
{
return [
@@ -2008,7 +2402,7 @@ class Application extends BaseModel
'config.build_pack' => 'required|string',
'config.base_directory' => 'required|string',
'config.publish_directory' => 'required|string',
'config.ports_exposes' => 'required|string',
'config.ports_exposes' => 'nullable|string',
'config.settings.is_static' => 'required|boolean',
]);
if ($deepValidator->fails()) {
+51 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Casts\EncryptedArrayCast;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
@@ -16,6 +17,10 @@ use OpenApi\Attributes as OA;
'application_id' => ['type' => 'string'],
'deployment_uuid' => ['type' => 'string'],
'pull_request_id' => ['type' => 'integer'],
'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true],
'configuration_hash' => ['type' => 'string', 'nullable' => true],
'configuration_snapshot' => ['type' => 'object', 'nullable' => true],
'configuration_diff' => ['type' => 'object', 'nullable' => true],
'force_rebuild' => ['type' => 'boolean'],
'commit' => ['type' => 'string'],
'status' => ['type' => 'string'],
@@ -39,10 +44,55 @@ use OpenApi\Attributes as OA;
)]
class ApplicationDeploymentQueue extends Model
{
protected $guarded = [];
protected $fillable = [
'application_id',
'deployment_uuid',
'pull_request_id',
'docker_registry_image_tag',
'configuration_hash',
'configuration_snapshot',
'configuration_diff',
'force_rebuild',
'commit',
'status',
'is_webhook',
'logs',
'current_process_id',
'restart_only',
'git_type',
'server_id',
'application_name',
'server_name',
'deployment_url',
'destination_id',
'only_this_server',
'rollback',
'commit_message',
'is_api',
'build_server_id',
'horizon_job_id',
'horizon_job_worker',
'finished_at',
];
/**
* The configuration snapshot/diff hold full (decrypted on read) configuration,
* including unlocked environment variable values. They are only meant for the
* in-app diff modal (which redacts per role) and must never be serialized by the
* API, so hide them globally as defense in depth.
*
* @var array<int, string>
*/
protected $hidden = [
'configuration_snapshot',
'configuration_diff',
];
protected $casts = [
'pull_request_id' => 'integer',
'finished_at' => 'datetime',
'configuration_snapshot' => EncryptedArrayCast::class,
'configuration_diff' => EncryptedArrayCast::class,
];
public function application()
+41 -7
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
@@ -10,7 +11,23 @@ class ApplicationPreview extends BaseModel
{
use SoftDeletes;
protected $guarded = [];
protected $fillable = [
'uuid',
'application_id',
'pull_request_id',
'pull_request_html_url',
'pull_request_issue_comment_id',
'fqdn',
'status',
'git_type',
'docker_compose_domains',
'docker_registry_image_tag',
'last_online_at',
];
protected $casts = [
'pull_request_id' => 'integer',
];
protected static function booted()
{
@@ -26,18 +43,25 @@ class ApplicationPreview extends BaseModel
$networkKeys = collect($networks)->keys();
$volumeKeys = collect($volumes)->keys();
$volumeKeys->each(function ($key) use ($server) {
instant_remote_process(["docker volume rm -f $key"], $server, false);
if (! preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $key)) {
return;
}
instant_remote_process(['docker volume rm -f '.escapeshellarg($key)], $server, false);
});
$networkKeys->each(function ($key) use ($server) {
instant_remote_process(["docker network disconnect $key coolify-proxy"], $server, false);
instant_remote_process(["docker network rm $key"], $server, false);
if (! preg_match(ValidationPatterns::DOCKER_NETWORK_PATTERN, $key)) {
return;
}
$k = escapeshellarg($key);
instant_remote_process(["docker network disconnect {$k} coolify-proxy"], $server, false);
instant_remote_process(["docker network rm {$k}"], $server, false);
});
} else {
// Regular application volume cleanup
$persistentStorages = $preview->persistentStorages()->get() ?? collect();
if ($persistentStorages->count() > 0) {
foreach ($persistentStorages as $storage) {
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
}
@@ -47,7 +71,7 @@ class ApplicationPreview extends BaseModel
});
static::saving(function ($preview) {
if ($preview->isDirty('status')) {
$preview->forceFill(['last_online_at' => now()]);
$preview->last_online_at = now();
}
});
}
@@ -69,7 +93,7 @@ class ApplicationPreview extends BaseModel
public function persistentStorages()
{
return $this->morphMany(\App\Models\LocalPersistentVolume::class, 'resource');
return $this->morphMany(LocalPersistentVolume::class, 'resource');
}
public function generate_preview_fqdn()
@@ -166,6 +190,16 @@ class ApplicationPreview extends BaseModel
}
$this->docker_compose_domains = json_encode($docker_compose_domains);
// Populate fqdn from generated domains so webhook notifications can read it
$allDomains = collect($docker_compose_domains)
->pluck('domain')
->filter(fn ($d) => ! empty($d))
->flatMap(fn ($d) => explode(',', $d))
->implode(',');
$this->fqdn = ! empty($allDomains) ? $allDomains : null;
$this->save();
}
}
+60 -1
View File
@@ -26,9 +26,68 @@ class ApplicationSetting extends Model
'is_git_lfs_enabled' => 'boolean',
'is_git_shallow_clone_enabled' => 'boolean',
'docker_images_to_keep' => 'integer',
'stop_grace_period' => 'integer',
];
protected $guarded = [];
protected $fillable = [
'application_id',
'is_static',
'is_git_submodules_enabled',
'is_git_lfs_enabled',
'is_auto_deploy_enabled',
'is_force_https_enabled',
'is_debug_enabled',
'is_preview_deployments_enabled',
'is_log_drain_enabled',
'is_gpu_enabled',
'gpu_driver',
'gpu_count',
'gpu_device_ids',
'gpu_options',
'is_include_timestamps',
'is_swarm_only_worker_nodes',
'is_raw_compose_deployment_enabled',
'is_build_server_enabled',
'is_consistent_container_name_enabled',
'is_gzip_enabled',
'is_stripprefix_enabled',
'connect_to_docker_network',
'custom_internal_name',
'is_container_label_escape_enabled',
'is_env_sorting_enabled',
'is_container_label_readonly_enabled',
'is_preserve_repository_enabled',
'disable_build_cache',
'is_spa',
'is_git_shallow_clone_enabled',
'is_pr_deployments_public_enabled',
'use_build_secrets',
'inject_build_args_to_dockerfile',
'include_source_commit_in_build',
'docker_images_to_keep',
'stop_grace_period',
];
public function stopGracePeriodSeconds(): int
{
if (
$this->stop_grace_period >= MIN_STOP_GRACE_PERIOD_SECONDS &&
$this->stop_grace_period <= MAX_STOP_GRACE_PERIOD_SECONDS
) {
return $this->stop_grace_period;
}
return DEFAULT_STOP_GRACE_PERIOD_SECONDS;
}
public function deploymentStopGracePeriodSeconds(): int
{
if (isDev() && $this->stop_grace_period === null) {
return MIN_STOP_GRACE_PERIOD_SECONDS;
}
return $this->stopGracePeriodSeconds();
}
public function isStatic(): Attribute
{
+6 -1
View File
@@ -4,7 +4,12 @@ namespace App\Models;
class CloudProviderToken extends BaseModel
{
protected $guarded = [];
protected $fillable = [
'team_id',
'provider',
'token',
'name',
];
protected $casts = [
'token' => 'encrypted',
+2 -1
View File
@@ -24,7 +24,8 @@ class DiscordNotificationSettings extends Model
'backup_failure_discord_notifications',
'scheduled_task_success_discord_notifications',
'scheduled_task_failure_discord_notifications',
'docker_cleanup_discord_notifications',
'docker_cleanup_success_discord_notifications',
'docker_cleanup_failure_discord_notifications',
'server_disk_usage_discord_notifications',
'server_reachable_discord_notifications',
'server_unreachable_discord_notifications',
+7 -1
View File
@@ -6,7 +6,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DockerCleanupExecution extends BaseModel
{
protected $guarded = [];
protected $fillable = [
'server_id',
'status',
'message',
'cleanup_log',
'finished_at',
];
public function server(): BelongsTo
{
+4
View File
@@ -34,7 +34,11 @@ class EmailNotificationSettings extends Model
'backup_failure_email_notifications',
'scheduled_task_success_email_notifications',
'scheduled_task_failure_email_notifications',
'docker_cleanup_success_email_notifications',
'docker_cleanup_failure_email_notifications',
'server_disk_usage_email_notifications',
'server_reachable_email_notifications',
'server_unreachable_email_notifications',
'server_patch_email_notifications',
'traefik_outdated_email_notifications',
];
+9 -2
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use OpenApi\Attributes as OA;
#[OA\Schema(
@@ -21,9 +22,15 @@ use OpenApi\Attributes as OA;
class Environment extends BaseModel
{
use ClearsGlobalSearchCache;
use HasFactory;
use HasSafeStringAttribute;
protected $guarded = [];
protected $fillable = [
'name',
'description',
'project_id',
'uuid',
];
protected static function booted()
{
@@ -56,7 +63,7 @@ class Environment extends BaseModel
public function environment_variables()
{
return $this->hasMany(SharedEnvironmentVariable::class);
return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'environment');
}
public function applications()
+143 -18
View File
@@ -3,6 +3,8 @@
namespace App\Models;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use OpenApi\Attributes as OA;
@@ -24,6 +26,7 @@ use OpenApi\Attributes as OA;
'key' => ['type' => 'string'],
'value' => ['type' => 'string'],
'real_value' => ['type' => 'string'],
'comment' => ['type' => 'string', 'nullable' => true],
'version' => ['type' => 'string'],
'created_at' => ['type' => 'string'],
'updated_at' => ['type' => 'string'],
@@ -31,7 +34,37 @@ use OpenApi\Attributes as OA;
)]
class EnvironmentVariable extends BaseModel
{
protected $guarded = [];
public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_'];
protected $attributes = [
'is_runtime' => true,
'is_buildtime' => true,
];
protected $fillable = [
// Core identification
'key',
'value',
'comment',
// Polymorphic relationship
'resourceable_type',
'resourceable_id',
// Boolean flags
'is_preview',
'is_multiline',
'is_literal',
'is_runtime',
'is_buildtime',
'is_shown_once',
'is_shared',
'is_required',
// Metadata
'version',
'order',
];
protected $casts = [
'key' => 'string',
@@ -45,11 +78,11 @@ class EnvironmentVariable extends BaseModel
'resourceable_id' => 'integer',
];
protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify'];
protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_buildpack_control', 'is_coolify'];
protected static function booted()
{
static::created(function (EnvironmentVariable $environment_variable) {
static::created(function (ModelsEnvironmentVariable $environment_variable) {
if ($environment_variable->resourceable_type === Application::class && ! $environment_variable->is_preview) {
$found = ModelsEnvironmentVariable::where('key', $environment_variable->key)
->where('resourceable_type', Application::class)
@@ -67,6 +100,7 @@ class EnvironmentVariable extends BaseModel
'is_literal' => $environment_variable->is_literal ?? false,
'is_runtime' => $environment_variable->is_runtime ?? false,
'is_buildtime' => $environment_variable->is_buildtime ?? false,
'comment' => $environment_variable->comment,
'resourceable_type' => Application::class,
'resourceable_id' => $environment_variable->resourceable_id,
'is_preview' => true,
@@ -79,7 +113,7 @@ class EnvironmentVariable extends BaseModel
]);
});
static::saving(function (EnvironmentVariable $environmentVariable) {
static::saving(function (ModelsEnvironmentVariable $environmentVariable) {
$environmentVariable->updateIsShared();
});
}
@@ -89,6 +123,30 @@ class EnvironmentVariable extends BaseModel
return $this->belongsTo(Service::class);
}
public function scopeWithoutBuildpackControlVariables(Builder $query): Builder
{
foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) {
$query->where('key', 'not like', "{$prefix}%");
}
return $query;
}
public static function isBuildpackControlKey(?string $key): bool
{
if (blank($key)) {
return false;
}
foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) {
if (str($key)->startsWith($prefix)) {
return true;
}
}
return false;
}
protected function value(): Attribute
{
return Attribute::make(
@@ -122,6 +180,17 @@ class EnvironmentVariable extends BaseModel
return null;
}
// Load relationships needed for shared variable resolution
if (! $resource->relationLoaded('environment')) {
$resource->load('environment');
}
if (! $resource->relationLoaded('server') && method_exists($resource, 'server')) {
$resource->load('server');
}
if (! $resource->relationLoaded('destination') && method_exists($resource, 'destination')) {
$resource->load('destination.server');
}
$real_value = $this->get_real_environment_variables($this->value, $resource);
// Skip escaping for valid JSON objects/arrays to prevent quote corruption (see #6160)
@@ -147,16 +216,10 @@ class EnvironmentVariable extends BaseModel
);
}
protected function isNixpacks(): Attribute
protected function isBuildpackControl(): Attribute
{
return Attribute::make(
get: function () {
if (str($this->key)->startsWith('NIXPACKS_')) {
return true;
}
return false;
}
get: fn () => self::isBuildpackControlKey($this->key),
);
}
@@ -187,10 +250,57 @@ class EnvironmentVariable extends BaseModel
);
}
public function get_real_environment_variables_with_server(?string $environment_variable = null, $resource = null, $server = null)
{
return $this->get_real_environment_variables_internal($environment_variable, $resource, $server);
}
public function getResolvedValueWithServer($server = null)
{
if (! $this->relationLoaded('resourceable')) {
$this->load('resourceable');
}
$resource = $this->resourceable;
if (! $resource) {
return null;
}
// Load relationships needed for shared variable resolution
if (! $resource->relationLoaded('environment')) {
$resource->load('environment');
}
if (! $resource->relationLoaded('server') && method_exists($resource, 'server')) {
$resource->load('server');
}
if (! $resource->relationLoaded('destination') && method_exists($resource, 'destination')) {
$resource->load('destination.server');
}
$real_value = $this->get_real_environment_variables_internal($this->value, $resource, $server);
// Skip escaping for valid JSON objects/arrays to prevent quote corruption (see #6160)
if (json_validate($real_value) && (str_starts_with($real_value, '{') || str_starts_with($real_value, '['))) {
return $real_value;
}
if ($this->is_literal || $this->is_multiline) {
$real_value = '\''.$real_value.'\'';
} else {
$real_value = escapeEnvVariables($real_value);
}
return $real_value;
}
private function get_real_environment_variables(?string $environment_variable = null, $resource = null)
{
if ((is_null($environment_variable) && $environment_variable === '') || is_null($resource)) {
return null;
return $this->get_real_environment_variables_internal($environment_variable, $resource);
}
private function get_real_environment_variables_internal(?string $environment_variable = null, $resource = null, $serverOverride = null)
{
if (is_null($environment_variable) || $environment_variable === '' || is_null($resource)) {
return $environment_variable;
}
$environment_variable = trim($environment_variable);
$sharedEnvsFound = str($environment_variable)->matchAll('/{{(.*?)}}/');
@@ -203,19 +313,32 @@ class EnvironmentVariable extends BaseModel
continue;
}
$variable = str($sharedEnv)->trim()->match('/\.(.*)/');
$id = null;
if ($type->value() === 'environment') {
$id = $resource->environment->id;
} elseif ($type->value() === 'project') {
$id = $resource->environment->project->id;
} elseif ($type->value() === 'team') {
$id = $resource->team()->id;
} elseif ($type->value() === 'server') {
if ($serverOverride) {
$id = $serverOverride->id;
} elseif (isset($resource->server) && $resource->server) {
$id = $resource->server->id;
} elseif (isset($resource->destination) && $resource->destination && isset($resource->destination->server)) {
$id = $resource->destination->server->id;
}
}
if (is_null($id)) {
continue;
}
$environment_variable_found = SharedEnvironmentVariable::where('type', $type)->where('key', $variable)->where('team_id', $resource->team()->id)->where("{$type}_id", $id)->first();
if ($environment_variable_found) {
$environment_variable = str($environment_variable)->replace("{{{$sharedEnv}}}", $environment_variable_found->value);
$found = SharedEnvironmentVariable::where('type', $type)
->where('key', $variable)
->where('team_id', $resource->team()->id)
->where("{$type}_id", $id)
->first();
if ($found) {
$environment_variable = str($environment_variable)->replace("{{{$sharedEnv}}}", $found->value);
}
}
@@ -248,7 +371,9 @@ class EnvironmentVariable extends BaseModel
protected function key(): Attribute
{
return Attribute::make(
set: fn (string $value) => str($value)->trim()->replace(' ', '_')->value,
set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey(
ValidationPatterns::normalizeEnvironmentVariableKey($value)
),
);
}
+22 -22
View File
@@ -6,7 +6,27 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
class GithubApp extends BaseModel
{
protected $guarded = [];
protected $fillable = [
'team_id',
'private_key_id',
'name',
'organization',
'api_url',
'html_url',
'custom_user',
'custom_port',
'app_id',
'installation_id',
'client_id',
'client_secret',
'webhook_secret',
'is_system_wide',
'is_public',
'contents',
'metadata',
'pull_requests',
'administration',
];
protected $appends = ['type'];
@@ -53,26 +73,6 @@ class GithubApp extends BaseModel
});
}
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);
@@ -92,7 +92,7 @@ class GithubApp extends BaseModel
{
return Attribute::make(
get: function () {
if ($this->getMorphClass() === \App\Models\GithubApp::class) {
if ($this->getMorphClass() === GithubApp::class) {
return 'github';
}
},
+18
View File
@@ -4,6 +4,24 @@ namespace App\Models;
class GitlabApp extends BaseModel
{
protected $fillable = [
'name',
'organization',
'api_url',
'html_url',
'custom_port',
'custom_user',
'is_system_wide',
'is_public',
'app_id',
'app_secret',
'oauth_id',
'group_name',
'public_key',
'webhook_token',
'deploy_key_id',
];
protected $hidden = [
'webhook_token',
'app_secret',
+39 -1
View File
@@ -9,7 +9,44 @@ use Spatie\Url\Url;
class InstanceSettings extends Model
{
protected $guarded = [];
protected $fillable = [
'public_ipv4',
'public_ipv6',
'fqdn',
'public_port_min',
'public_port_max',
'do_not_track',
'is_auto_update_enabled',
'is_registration_enabled',
'next_channel',
'smtp_enabled',
'smtp_from_address',
'smtp_from_name',
'smtp_recipients',
'smtp_host',
'smtp_port',
'smtp_encryption',
'smtp_username',
'smtp_password',
'smtp_timeout',
'resend_enabled',
'resend_api_key',
'is_dns_validation_enabled',
'custom_dns_servers',
'instance_name',
'is_api_enabled',
'allowed_ips',
'auto_update_frequency',
'update_check_frequency',
'new_version_available',
'instance_timezone',
'helper_version',
'disable_two_step_confirmation',
'is_sponsorship_popup_enabled',
'dev_helper_version',
'is_wire_navigate_enabled',
'is_mcp_server_enabled',
];
protected $casts = [
'smtp_enabled' => 'boolean',
@@ -31,6 +68,7 @@ class InstanceSettings extends Model
'update_check_frequency' => 'string',
'sentinel_token' => 'encrypted',
'is_wire_navigate_enabled' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
];
protected static function booted(): void
+67 -14
View File
@@ -3,39 +3,63 @@
namespace App\Models;
use App\Events\FileStorageChanged;
use App\Jobs\ServerStorageSaveJob;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Symfony\Component\Yaml\Yaml;
class LocalFileVolume extends BaseModel
{
public const MAX_CONTENT_SIZE = 5_242_880;
public const BINARY_PLACEHOLDER = '[binary file]';
public const TOO_LARGE_PLACEHOLDER = '[file too large to display]';
protected $casts = [
// 'fs_path' => 'encrypted',
// 'mount_path' => 'encrypted',
'content' => 'encrypted',
'is_directory' => 'boolean',
'is_preview_suffix_enabled' => 'boolean',
];
use HasFactory;
protected $guarded = [];
protected $fillable = [
'fs_path',
'mount_path',
'content',
'resource_type',
'resource_id',
'is_directory',
'chown',
'chmod',
'is_based_on_git',
'is_preview_suffix_enabled',
];
public $appends = ['is_binary'];
public $appends = ['is_binary', 'is_too_large'];
protected static function booted()
{
static::created(function (LocalFileVolume $fileVolume) {
$fileVolume->load(['service']);
dispatch(new \App\Jobs\ServerStorageSaveJob($fileVolume));
dispatch(new ServerStorageSaveJob($fileVolume));
});
}
protected function isBinary(): Attribute
{
return Attribute::make(
get: function () {
return $this->content === '[binary file]';
}
get: fn () => $this->content === self::BINARY_PLACEHOLDER
);
}
protected function isTooLarge(): Attribute
{
return Attribute::make(
get: fn () => $this->content === self::TOO_LARGE_PLACEHOLDER
);
}
@@ -68,10 +92,17 @@ class LocalFileVolume extends BaseModel
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK') {
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
$this->content = self::TOO_LARGE_PLACEHOLDER;
$this->is_directory = false;
$this->save();
return;
}
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
// Check if content contains binary data by looking for null bytes or non-printable characters
if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) {
$content = '[binary file]';
$content = self::BINARY_PLACEHOLDER;
}
$this->content = $content;
$this->is_directory = false;
@@ -79,6 +110,18 @@ class LocalFileVolume extends BaseModel
}
}
protected function remoteFileExceedsLimit(string $escapedPath, $server): bool
{
$sizeOutput = instant_remote_process(
["stat -c%s {$escapedPath} 2>/dev/null || wc -c < {$escapedPath}"],
$server,
false,
);
$size = (int) trim((string) $sizeOutput);
return $size > self::MAX_CONTENT_SIZE;
}
public function deleteStorageOnServer()
{
$this->load(['service']);
@@ -128,15 +171,22 @@ class LocalFileVolume extends BaseModel
$server = $this->resource->destination->server;
}
$commands = collect([]);
// Validate fs_path early before any shell interpolation
validateShellSafePath($this->fs_path, 'storage path');
$escapedFsPath = escapeshellarg($this->fs_path);
$escapedWorkdir = escapeshellarg($workdir);
if ($this->is_directory) {
$commands->push("mkdir -p $this->fs_path > /dev/null 2>&1 || true");
$commands->push("mkdir -p $workdir > /dev/null 2>&1 || true");
$commands->push("cd $workdir");
$commands->push("mkdir -p {$escapedFsPath} > /dev/null 2>&1 || true");
$commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true");
$commands->push("cd {$escapedWorkdir}");
}
if (str($this->fs_path)->startsWith('.') || str($this->fs_path)->startsWith('/') || str($this->fs_path)->startsWith('~')) {
$parent_dir = str($this->fs_path)->beforeLast('/');
if ($parent_dir != '') {
$commands->push("mkdir -p $parent_dir > /dev/null 2>&1 || true");
$escapedParentDir = escapeshellarg($parent_dir);
$commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true");
}
}
$path = data_get_str($this, 'fs_path');
@@ -146,16 +196,19 @@ class LocalFileVolume extends BaseModel
$path = $workdir.$path;
}
// Validate and escape path to prevent command injection
// Validate and escape resolved path (may differ from fs_path if relative)
validateShellSafePath($path, 'storage path');
$escapedPath = escapeshellarg($path);
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK' && $this->is_directory) {
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
$this->content = self::TOO_LARGE_PLACEHOLDER;
} else {
$this->content = instant_remote_process(["cat {$escapedPath}"], $server, false);
}
$this->is_directory = false;
$this->content = $content;
$this->save();
FileStorageChanged::dispatch(data_get($server, 'team_id'));
throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.');
+14 -3
View File
@@ -3,12 +3,23 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\Yaml\Yaml;
class LocalPersistentVolume extends Model
class LocalPersistentVolume extends BaseModel
{
protected $guarded = [];
protected $fillable = [
'name',
'mount_path',
'host_path',
'container_id',
'resource_type',
'resource_id',
'is_preview_suffix_enabled',
];
protected $casts = [
'is_preview_suffix_enabled' => 'boolean',
];
public function resource()
{
+8
View File
@@ -11,6 +11,14 @@ class PersonalAccessToken extends SanctumPersonalAccessToken
'token',
'abilities',
'expires_at',
'api_token_expiration_warning_sent_at',
'team_id',
];
protected function casts(): array
{
return [
'api_token_expiration_warning_sent_at' => 'datetime',
];
}
}
+56 -22
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\HasSafeStringAttribute;
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use OpenApi\Attributes as OA;
@@ -65,6 +66,20 @@ class PrivateKey extends BaseModel
}
});
static::saved(function ($key) {
if ($key->wasChanged('private_key')) {
try {
$key->storeInFileSystem();
refresh_server_connection($key);
} catch (\Exception $e) {
Log::error('Failed to resync SSH key after update', [
'key_uuid' => $key->uuid,
'error' => $e->getMessage(),
]);
}
}
});
static::deleted(function ($key) {
self::deleteFromStorage($key);
});
@@ -185,29 +200,54 @@ class PrivateKey extends BaseModel
{
$filename = "ssh_key@{$this->uuid}";
$disk = Storage::disk('ssh-keys');
$keyLocation = $this->getKeyLocation();
$lockFile = $keyLocation.'.lock';
// Ensure the storage directory exists and is writable
$this->ensureStorageDirectoryExists();
// Attempt to store the private key
$success = $disk->put($filename, $this->private_key);
if (! $success) {
throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$this->getKeyLocation()}");
// Use file locking to prevent concurrent writes from corrupting the key
$lockHandle = fopen($lockFile, 'c');
if ($lockHandle === false) {
throw new \Exception("Failed to open lock file for SSH key: {$lockFile}");
}
// Verify the file was actually created and has content
if (! $disk->exists($filename)) {
throw new \Exception("SSH key file was not created: {$this->getKeyLocation()}");
}
try {
if (! flock($lockHandle, LOCK_EX)) {
throw new \Exception("Failed to acquire lock for SSH key: {$keyLocation}");
}
$storedContent = $disk->get($filename);
if (empty($storedContent) || $storedContent !== $this->private_key) {
$disk->delete($filename); // Clean up the bad file
throw new \Exception("SSH key file content verification failed: {$this->getKeyLocation()}");
}
// Attempt to store the private key
$success = $disk->put($filename, $this->private_key);
return $this->getKeyLocation();
if (! $success) {
throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$keyLocation}");
}
// Verify the file was actually created and has content
if (! $disk->exists($filename)) {
throw new \Exception("SSH key file was not created: {$keyLocation}");
}
$storedContent = $disk->get($filename);
if (empty($storedContent) || $storedContent !== $this->private_key) {
$disk->delete($filename); // Clean up the bad file
throw new \Exception("SSH key file content verification failed: {$keyLocation}");
}
// Ensure correct permissions for SSH (0600 required)
if (file_exists($keyLocation) && ! chmod($keyLocation, 0600)) {
Log::warning('Failed to set SSH key file permissions to 0600', [
'key_uuid' => $this->uuid,
'path' => $keyLocation,
]);
}
return $keyLocation;
} finally {
flock($lockHandle, LOCK_UN);
fclose($lockHandle);
}
}
public static function deleteFromStorage(self $privateKey)
@@ -237,7 +277,7 @@ class PrivateKey extends BaseModel
$testSuccess = $disk->put($testFilename, 'test');
if (! $testSuccess) {
throw new \Exception('SSH keys storage directory is not writable');
throw new \Exception('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify');
}
// Clean up test file
@@ -254,12 +294,6 @@ class PrivateKey extends BaseModel
return DB::transaction(function () use ($data) {
$this->update($data);
try {
$this->storeInFileSystem();
} catch (\Exception $e) {
throw new \Exception('Failed to update SSH key: '.$e->getMessage());
}
return $this;
});
}
+9 -2
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use OpenApi\Attributes as OA;
use Visus\Cuid2\Cuid2;
@@ -20,9 +21,15 @@ use Visus\Cuid2\Cuid2;
class Project extends BaseModel
{
use ClearsGlobalSearchCache;
use HasFactory;
use HasSafeStringAttribute;
protected $guarded = [];
protected $fillable = [
'name',
'description',
'team_id',
'uuid',
];
/**
* Get query builder for projects owned by current team.
@@ -67,7 +74,7 @@ class Project extends BaseModel
public function environment_variables()
{
return $this->hasMany(SharedEnvironmentVariable::class);
return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'project');
}
public function environments()
+3 -1
View File
@@ -6,7 +6,9 @@ use Illuminate\Database\Eloquent\Model;
class ProjectSetting extends Model
{
protected $guarded = [];
protected $fillable = [
'project_id',
];
public function project()
{
+2 -1
View File
@@ -25,7 +25,8 @@ class PushoverNotificationSettings extends Model
'backup_failure_pushover_notifications',
'scheduled_task_success_pushover_notifications',
'scheduled_task_failure_pushover_notifications',
'docker_cleanup_pushover_notifications',
'docker_cleanup_success_pushover_notifications',
'docker_cleanup_failure_pushover_notifications',
'server_disk_usage_pushover_notifications',
'server_reachable_pushover_notifications',
'server_unreachable_pushover_notifications',
+66 -3
View File
@@ -2,17 +2,34 @@
namespace App\Models;
use App\Rules\SafeWebhookUrl;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
class S3Storage extends BaseModel
{
use HasFactory, HasSafeStringAttribute;
protected $guarded = [];
private const CONNECTION_TIMEOUT_SECONDS = 15;
private const REQUEST_TIMEOUT_SECONDS = 15;
protected $fillable = [
'team_id',
'name',
'description',
'region',
'key',
'secret',
'bucket',
'endpoint',
'is_usable',
'unusable_email_sent',
];
protected $casts = [
'is_usable' => 'boolean',
@@ -40,6 +57,13 @@ class S3Storage extends BaseModel
$storage->secret = trim($storage->secret);
}
});
static::deleting(function (S3Storage $storage) {
ScheduledDatabaseBackup::where('s3_storage_id', $storage->id)->update([
'save_s3' => false,
's3_storage_id' => null,
]);
});
}
public static function ownedByCurrentTeam(array $select = ['*'])
@@ -49,6 +73,13 @@ class S3Storage extends BaseModel
return S3Storage::whereTeamId(currentTeam()->id)->select($selectArray->all())->orderBy('name');
}
public static function ownedByCurrentTeamAPI(int $teamId, array $select = ['*'])
{
$selectArray = collect($select)->concat(['id']);
return S3Storage::whereTeamId($teamId)->select($selectArray->all())->orderBy('name');
}
public function isUsable()
{
return $this->is_usable;
@@ -59,6 +90,11 @@ class S3Storage extends BaseModel
return $this->belongsTo(Team::class);
}
public function scheduledBackups()
{
return $this->hasMany(ScheduledDatabaseBackup::class, 's3_storage_id');
}
public function awsUrl()
{
return "{$this->endpoint}/{$this->bucket}";
@@ -110,6 +146,14 @@ class S3Storage extends BaseModel
public function testConnection(bool $shouldSave = false)
{
try {
$validator = Validator::make(
['endpoint' => $this['endpoint']],
['endpoint' => ['required', new SafeWebhookUrl]],
);
if ($validator->fails()) {
throw new \RuntimeException('S3 endpoint is not allowed: '.$validator->errors()->first('endpoint'));
}
$disk = Storage::build([
'driver' => 's3',
'region' => $this['region'],
@@ -118,6 +162,10 @@ class S3Storage extends BaseModel
'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();
@@ -125,11 +173,12 @@ class S3Storage extends BaseModel
$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()) {
$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) {
@@ -144,11 +193,25 @@ class S3Storage extends BaseModel
$this->unusable_email_sent = true;
}
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;
}
}
+29 -1
View File
@@ -8,7 +8,35 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
class ScheduledDatabaseBackup extends BaseModel
{
protected $guarded = [];
protected function casts(): array
{
return [
'database_backup_retention_max_storage_locally' => 'float',
'database_backup_retention_max_storage_s3' => 'float',
];
}
protected $fillable = [
'uuid',
'team_id',
'description',
'enabled',
'save_s3',
'frequency',
'database_backup_retention_amount_locally',
'database_type',
'database_id',
's3_storage_id',
'databases_to_backup',
'dump_all',
'database_backup_retention_days_locally',
'database_backup_retention_max_storage_locally',
'database_backup_retention_amount_s3',
'database_backup_retention_days_s3',
'database_backup_retention_max_storage_s3',
'timeout',
'disable_local_backup',
];
public static function ownedByCurrentTeam()
{
@@ -6,11 +6,24 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ScheduledDatabaseBackupExecution extends BaseModel
{
protected $guarded = [];
protected $fillable = [
'uuid',
'scheduled_database_backup_id',
'status',
'message',
'size',
'filename',
'database_name',
'finished_at',
'local_storage_deleted',
's3_storage_deleted',
's3_uploaded',
];
protected function casts(): array
{
return [
'size' => 'integer',
's3_uploaded' => 'boolean',
'local_storage_deleted' => 'boolean',
's3_storage_deleted' => 'boolean',
+42 -13
View File
@@ -3,14 +3,49 @@
namespace App\Models;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use OpenApi\Attributes as OA;
#[OA\Schema(
description: 'Scheduled Task model',
type: 'object',
properties: [
'id' => ['type' => 'integer', 'description' => 'The unique identifier of the scheduled task in the database.'],
'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the scheduled task.'],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.'],
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.'],
'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was created.'],
'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was last updated.'],
],
)]
class ScheduledTask extends BaseModel
{
use HasFactory;
use HasSafeStringAttribute;
protected $guarded = [];
protected $fillable = [
'uuid',
'enabled',
'name',
'command',
'frequency',
'container',
'timeout',
'team_id',
'application_id',
'service_id',
];
public static function ownedByCurrentTeamAPI(int $teamId)
{
return static::where('team_id', $teamId)->orderBy('created_at', 'desc');
}
protected function casts(): array
{
@@ -41,20 +76,14 @@ class ScheduledTask extends BaseModel
return $this->hasMany(ScheduledTaskExecution::class)->orderBy('created_at', 'desc');
}
public function server()
public function server(): ?Server
{
if ($this->application) {
if ($this->application->destination && $this->application->destination->server) {
return $this->application->destination->server;
}
} elseif ($this->service) {
if ($this->service->destination && $this->service->destination->server) {
return $this->service->destination->server;
}
} elseif ($this->database) {
if ($this->database->destination && $this->database->destination->server) {
return $this->database->destination->server;
}
return $this->application->destination?->server;
}
if ($this->service) {
return $this->service->destination?->server;
}
return null;
+26 -1
View File
@@ -3,10 +3,35 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use OpenApi\Attributes as OA;
#[OA\Schema(
description: 'Scheduled Task Execution model',
type: 'object',
properties: [
'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the execution.'],
'status' => ['type' => 'string', 'enum' => ['success', 'failed', 'running'], 'description' => 'The status of the execution.'],
'message' => ['type' => 'string', 'nullable' => true, 'description' => 'The output message of the execution.'],
'retry_count' => ['type' => 'integer', 'description' => 'The number of retries.'],
'duration' => ['type' => 'number', 'nullable' => true, 'description' => 'Duration in seconds.'],
'started_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution started.'],
'finished_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution finished.'],
'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was created.'],
'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was last updated.'],
],
)]
class ScheduledTaskExecution extends BaseModel
{
protected $guarded = [];
protected $fillable = [
'scheduled_task_id',
'status',
'message',
'finished_at',
'started_at',
'retry_count',
'duration',
'error_details',
];
protected function casts(): array
{
+137 -50
View File
@@ -11,10 +11,13 @@ use App\Enums\ProxyTypes;
use App\Events\ServerReachabilityChanged;
use App\Helpers\SslHelper;
use App\Jobs\CheckAndStartSentinelJob;
use App\Jobs\CheckTraefikVersionForServerJob;
use App\Jobs\RegenerateSslCertJob;
use App\Livewire\Server\Proxy;
use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable;
use App\Services\ConfigurationRepository;
use App\Support\ValidationPatterns;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
@@ -25,12 +28,14 @@ use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Stringable;
use OpenApi\Attributes as OA;
use Spatie\SchemalessAttributes\Casts\SchemalessAttributes;
use Spatie\SchemalessAttributes\SchemalessAttributesTrait;
use Spatie\Url\Url;
use Stevebauman\Purify\Facades\Purify;
use Symfony\Component\Yaml\Yaml;
use Visus\Cuid2\Cuid2;
@@ -76,8 +81,8 @@ use Visus\Cuid2\Cuid2;
* - Traefik image uses the 'latest' tag (no fixed version tracking)
* - No Traefik version detected on the server
*
* @see \App\Jobs\CheckTraefikVersionForServerJob Where this data is populated
* @see \App\Livewire\Server\Proxy Where this data is read and displayed
* @see CheckTraefikVersionForServerJob Where this data is populated
* @see Proxy Where this data is read and displayed
*/
#[OA\Schema(
description: 'Server model',
@@ -131,10 +136,10 @@ class Server extends BaseModel
$payload['ip_previous'] = $server->getOriginal('ip');
}
}
$server->forceFill($payload);
$server->fill($payload);
});
static::saved(function ($server) {
if ($server->privateKey?->isDirty()) {
if ($server->wasChanged('private_key_id') || $server->privateKey?->isDirty()) {
refresh_server_connection($server->privateKey);
}
});
@@ -144,19 +149,14 @@ class Server extends BaseModel
]);
if ($server->id === 0) {
if ($server->isSwarm()) {
SwarmDocker::create([
(new SwarmDocker)->forceFill([
'id' => 0,
'name' => 'coolify',
'network' => 'coolify-overlay',
'server_id' => $server->id,
]);
])->save();
} else {
StandaloneDocker::create([
'id' => 0,
'name' => 'coolify',
'network' => 'coolify',
'server_id' => $server->id,
]);
(new StandaloneDocker)->forceFill($server->defaultStandaloneDockerAttributes(id: 0))->saveQuietly();
}
} else {
if ($server->isSwarm()) {
@@ -166,18 +166,32 @@ class Server extends BaseModel
'server_id' => $server->id,
]);
} else {
$standaloneDocker = new StandaloneDocker([
'name' => 'coolify',
'uuid' => (string) new Cuid2,
'network' => 'coolify',
'server_id' => $server->id,
]);
$standaloneDocker = new StandaloneDocker;
$standaloneDocker->forceFill($server->defaultStandaloneDockerAttributes());
$standaloneDocker->saveQuietly();
}
}
if (! isset($server->proxy->redirect_enabled)) {
$server->proxy->redirect_enabled = true;
}
// Create predefined server shared variables
SharedEnvironmentVariable::create([
'key' => 'COOLIFY_SERVER_UUID',
'value' => $server->uuid,
'type' => 'server',
'server_id' => $server->id,
'team_id' => $server->team_id,
'is_literal' => true,
]);
SharedEnvironmentVariable::create([
'key' => 'COOLIFY_SERVER_NAME',
'value' => $server->name,
'type' => 'server',
'server_id' => $server->id,
'team_id' => $server->team_id,
'is_literal' => true,
]);
});
static::retrieved(function ($server) {
if (! isset($server->proxy->redirect_enabled)) {
@@ -231,6 +245,7 @@ class Server extends BaseModel
protected $casts = [
'proxy' => SchemalessAttributes::class,
'traefik_outdated_info' => 'array',
'server_metadata' => 'array',
'logdrain_axiom_api_key' => 'encrypted',
'logdrain_newrelic_license_key' => 'encrypted',
'delete_unused_volumes' => 'boolean',
@@ -258,12 +273,19 @@ class Server extends BaseModel
'is_validating',
'detected_traefik_version',
'traefik_outdated_info',
'server_metadata',
'ip_previous',
];
protected $guarded = [];
use HasSafeStringAttribute;
public function setValidationLogsAttribute($value): void
{
$this->attributes['validation_logs'] = $value !== null
? Purify::config('validation_logs')->clean($value)
: null;
}
public function type()
{
return 'server';
@@ -716,17 +738,17 @@ $schema://$host {
public function stopUnmanaged($id)
{
return instant_remote_process(["docker stop -t 0 $id"], $this);
return instant_remote_process(['docker stop -t 0 '.escapeshellarg($id)], $this);
}
public function restartUnmanaged($id)
{
return instant_remote_process(["docker restart $id"], $this);
return instant_remote_process(['docker restart '.escapeshellarg($id)], $this);
}
public function startUnmanaged($id)
{
return instant_remote_process(["docker start $id"], $this);
return instant_remote_process(['docker start '.escapeshellarg($id)], $this);
}
public function getContainers()
@@ -913,6 +935,9 @@ $schema://$host {
return Attribute::make(
get: function ($value) {
return (int) preg_replace('/[^0-9]/', '', $value);
},
set: function ($value) {
return (int) preg_replace('/[^0-9]/', '', (string) $value);
}
);
}
@@ -921,7 +946,10 @@ $schema://$host {
{
return Attribute::make(
get: function ($value) {
return preg_replace('/[^A-Za-z0-9\-_]/', '', $value);
return preg_replace(ValidationPatterns::INVALID_SERVER_USERNAME_CHARACTERS_PATTERN, '', $value);
},
set: function ($value) {
return preg_replace(ValidationPatterns::INVALID_SERVER_USERNAME_CHARACTERS_PATTERN, '', $value);
}
);
}
@@ -931,6 +959,9 @@ $schema://$host {
return Attribute::make(
get: function ($value) {
return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value);
},
set: function ($value) {
return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value);
}
);
}
@@ -1003,6 +1034,30 @@ $schema://$host {
return $this->belongsTo(Team::class);
}
/**
* @return array{id?: int, name: string, uuid: string, network: string, server_id: int}
*/
public function defaultStandaloneDockerAttributes(?int $id = null): array
{
$attributes = [
'name' => 'coolify',
'uuid' => (string) new Cuid2,
'network' => 'coolify',
'server_id' => $this->id,
];
if (! is_null($id)) {
$attributes['id'] = $id;
}
return $attributes;
}
public function environment_variables()
{
return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'server');
}
public function isProxyShouldRun()
{
// TODO: Do we need "|| $this->proxy->force_stop" here?
@@ -1065,6 +1120,55 @@ $schema://$host {
}
}
public function gatherServerMetadata(): ?array
{
if (! $this->isFunctional()) {
return null;
}
try {
$output = instant_remote_process([
'echo "---PRETTY_NAME---" && grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d \'"\' && echo "---ARCH---" && uname -m && echo "---KERNEL---" && uname -r && echo "---CPUS---" && nproc && echo "---MEMORY---" && free -b | awk \'/Mem:/{print $2}\' && echo "---UPTIME_SINCE---" && uptime -s',
], $this, false);
if (! $output) {
return null;
}
$sections = [];
$currentKey = null;
foreach (explode("\n", trim($output)) as $line) {
$line = trim($line);
if (preg_match('/^---(\w+)---$/', $line, $m)) {
$currentKey = $m[1];
} elseif ($currentKey) {
$sections[$currentKey] = $line;
}
}
$metadata = [
'os' => $sections['PRETTY_NAME'] ?? 'Unknown',
'arch' => $sections['ARCH'] ?? 'Unknown',
'kernel' => $sections['KERNEL'] ?? 'Unknown',
'cpus' => (int) ($sections['CPUS'] ?? 0),
'memory_bytes' => (int) ($sections['MEMORY'] ?? 0),
'uptime_since' => $sections['UPTIME_SINCE'] ?? null,
'collected_at' => now()->toIso8601String(),
];
$this->update(['server_metadata' => $metadata]);
return $metadata;
} catch (\Throwable $e) {
Log::debug('Failed to gather server metadata', [
'server_id' => $this->id,
'error' => $e->getMessage(),
]);
return null;
}
}
public function isTerminalEnabled()
{
return $this->settings->is_terminal_enabled ?? false;
@@ -1133,10 +1237,8 @@ $schema://$host {
$this->refresh();
$unreachableNotificationSent = (bool) $this->unreachable_notification_sent;
$isReachable = (bool) $this->settings->is_reachable;
if ($isReachable === true) {
$this->unreachable_count = 0;
$this->save();
if ($isReachable === true) {
if ($unreachableNotificationSent === true) {
$this->sendReachableNotification();
}
@@ -1144,28 +1246,8 @@ $schema://$host {
return;
}
$this->increment('unreachable_count');
if ($this->unreachable_count === 1) {
$this->settings->is_reachable = true;
$this->settings->save();
return;
}
if ($this->unreachable_count >= 2 && ! $unreachableNotificationSent) {
$failedChecks = 0;
for ($i = 0; $i < 3; $i++) {
$status = $this->serverStatus();
sleep(5);
if (! $status) {
$failedChecks++;
}
}
if ($failedChecks === 3 && ! $unreachableNotificationSent) {
$this->sendUnreachableNotification();
}
$this->sendUnreachableNotification();
}
}
@@ -1399,7 +1481,7 @@ $schema://$host {
public function restartContainer(string $containerName)
{
return instant_remote_process(['docker restart '.$containerName], $this, false);
return instant_remote_process(['docker restart '.escapeshellarg($containerName)], $this, false);
}
public function changeProxy(string $proxyType, bool $async = true)
@@ -1410,6 +1492,9 @@ $schema://$host {
if ($validProxyTypes->contains(str($proxyType)->lower())) {
$this->proxy->set('type', str($proxyType)->upper());
$this->proxy->set('status', 'exited');
$this->proxy->set('last_saved_proxy_configuration', null);
$this->proxy->set('last_saved_settings', null);
$this->proxy->set('last_applied_settings', null);
$this->save();
if ($this->proxySet()) {
if ($async) {
@@ -1452,12 +1537,14 @@ $schema://$host {
$certificateContent = $caCertificate->ssl_certificate;
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
$base64Cert = base64_encode($certificateContent);
$commands = collect([
"mkdir -p $caCertPath",
"chown -R 9999:root $caCertPath",
"chmod -R 700 $caCertPath",
"rm -rf $caCertPath/coolify-ca.crt",
"echo '{$certificateContent}' > $caCertPath/coolify-ca.crt",
"echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
"chmod 644 $caCertPath/coolify-ca.crt",
]);
+98 -5
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
@@ -49,13 +50,60 @@ use OpenApi\Attributes as OA;
'updated_at' => ['type' => 'string'],
'delete_unused_volumes' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused volumes should be deleted.'],
'delete_unused_networks' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused networks should be deleted.'],
'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds.'],
]
)]
class ServerSetting extends Model
{
protected $guarded = [];
protected $fillable = [
'server_id',
'is_swarm_manager',
'is_jump_server',
'is_build_server',
'is_reachable',
'is_usable',
'wildcard_domain',
'is_cloudflare_tunnel',
'is_logdrain_newrelic_enabled',
'logdrain_newrelic_license_key',
'logdrain_newrelic_base_uri',
'is_logdrain_highlight_enabled',
'logdrain_highlight_project_id',
'is_logdrain_axiom_enabled',
'logdrain_axiom_dataset_name',
'logdrain_axiom_api_key',
'is_swarm_worker',
'is_logdrain_custom_enabled',
'logdrain_custom_config',
'logdrain_custom_config_parser',
'concurrent_builds',
'dynamic_timeout',
'force_disabled',
'is_metrics_enabled',
'generate_exact_labels',
'force_docker_cleanup',
'docker_cleanup_frequency',
'docker_cleanup_threshold',
'server_timezone',
'delete_unused_volumes',
'delete_unused_networks',
'is_sentinel_enabled',
'sentinel_token',
'sentinel_metrics_refresh_rate_seconds',
'sentinel_metrics_history_days',
'sentinel_push_interval_seconds',
'sentinel_custom_url',
'server_disk_usage_notification_threshold',
'is_sentinel_debug_enabled',
'server_disk_usage_check_frequency',
'is_terminal_enabled',
'deployment_queue_limit',
'disable_application_image_retention',
'connection_timeout',
];
protected $casts = [
'force_disabled' => 'boolean',
'force_docker_cleanup' => 'boolean',
'docker_cleanup_threshold' => 'integer',
'sentinel_token' => 'encrypted',
@@ -63,6 +111,7 @@ class ServerSetting extends Model
'is_usable' => 'boolean',
'is_terminal_enabled' => 'boolean',
'disable_application_image_retention' => 'boolean',
'connection_timeout' => 'integer',
];
protected static function booted()
@@ -92,14 +141,58 @@ class ServerSetting extends Model
});
}
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false)
/**
* Validate that a sentinel token contains only safe characters.
* Prevents OS command injection when the token is interpolated into shell commands.
*/
public static function isValidSentinelToken(?string $token): bool
{
if ($token === null) {
return false;
}
return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token);
}
/**
* Returns a valid sentinel token, regenerating it if the stored value is
* empty, undecryptable, or otherwise invalid. Throws only when regeneration
* still fails to produce a valid token.
*/
public function ensureValidSentinelToken(): string
{
try {
$token = $this->sentinel_token;
} catch (DecryptException) {
$token = null;
}
if (! self::isValidSentinelToken($token)) {
// Clear undecryptable raw value so Eloquent's dirty-check won't try to
// decrypt the bad original during save().
$attrs = $this->getAttributes();
$attrs['sentinel_token'] = null;
$this->setRawAttributes($attrs, true);
$this->generateSentinelToken(save: true, ignoreEvent: true);
$this->refresh();
$token = $this->sentinel_token;
}
if (! self::isValidSentinelToken($token)) {
throw new \RuntimeException('Sentinel token invalid after regeneration. Allowed characters: a-z, A-Z, 0-9, dot, underscore, hyphen, plus, slash, equals.');
}
return $token;
}
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false): string
{
$data = [
'server_uuid' => $this->server->uuid,
];
$token = json_encode($data);
$encrypted = encrypt($token);
$this->sentinel_token = $encrypted;
$token = encrypt(json_encode($data));
$this->sentinel_token = $token;
if ($save) {
if ($ignoreEvent) {
$this->saveQuietly();
+20 -3
View File
@@ -15,6 +15,7 @@ use Illuminate\Support\Facades\Storage;
use OpenApi\Attributes as OA;
use Spatie\Activitylog\Models\Activity;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Visus\Cuid2\Cuid2;
#[OA\Schema(
@@ -47,7 +48,22 @@ class Service extends BaseModel
private static $parserVersion = '5';
protected $guarded = [];
protected $fillable = [
'uuid',
'name',
'description',
'docker_compose_raw',
'docker_compose',
'connect_to_docker_network',
'service_type',
'config_hash',
'compose_parsing_version',
'is_container_label_escape_enabled',
'environment_id',
'server_id',
'destination_id',
'destination_type',
];
protected $appends = ['server_status', 'status'];
@@ -762,7 +778,8 @@ class Service extends BaseModel
}
$rpc_secret = $this->environment_variables()->where('key', 'GARAGE_RPC_SECRET')->first();
if (is_null($rpc_secret)) {
$rpc_secret = $this->environment_variables()->where('key', 'SERVICE_HEX_32_RPCSECRET')->first();
$rpc_secret = $this->environment_variables()->where('key', 'SERVICE_HEX_64_RPCSECRET')->first()
?? $this->environment_variables()->where('key', 'SERVICE_HEX_32_RPCSECRET')->first();
}
$metrics_token = $this->environment_variables()->where('key', 'GARAGE_METRICS_TOKEN')->first();
if (is_null($metrics_token)) {
@@ -1552,7 +1569,7 @@ class Service extends BaseModel
// Generate SERVICE_NAME_* environment variables from docker-compose services
if ($this->docker_compose) {
try {
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($this->docker_compose);
$dockerCompose = Yaml::parse($this->docker_compose);
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
$envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName);
+23 -4
View File
@@ -5,12 +5,31 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Symfony\Component\Yaml\Yaml;
class ServiceApplication extends BaseModel
{
use HasFactory, SoftDeletes;
protected $guarded = [];
protected $fillable = [
'service_id',
'name',
'human_name',
'description',
'fqdn',
'ports',
'exposes',
'status',
'exclude_from_status',
'required_fqdn',
'image',
'is_log_drain_enabled',
'is_include_timestamps',
'is_gzip_enabled',
'is_stripprefix_enabled',
'last_online_at',
'is_migrated',
];
protected static function booted()
{
@@ -21,7 +40,7 @@ class ServiceApplication extends BaseModel
});
static::saving(function ($service) {
if ($service->isDirty('status')) {
$service->forceFill(['last_online_at' => now()]);
$service->last_online_at = now();
}
});
}
@@ -88,7 +107,7 @@ class ServiceApplication extends BaseModel
public function team()
{
return data_get($this, 'environment.project.team');
return data_get($this, 'service.environment.project.team');
}
public function workdir()
@@ -211,7 +230,7 @@ class ServiceApplication extends BaseModel
return $this->service->getRequiredPort();
}
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
$dockerCompose = Yaml::parse($dockerComposeRaw);
$serviceConfig = data_get($dockerCompose, "services.{$this->name}");
if (! $serviceConfig) {
return $this->service->getRequiredPort();
+28 -3
View File
@@ -9,7 +9,32 @@ class ServiceDatabase extends BaseModel
{
use HasFactory, SoftDeletes;
protected $guarded = [];
protected $fillable = [
'service_id',
'name',
'human_name',
'description',
'fqdn',
'ports',
'exposes',
'status',
'exclude_from_status',
'image',
'public_port',
'is_public',
'is_log_drain_enabled',
'is_include_timestamps',
'is_gzip_enabled',
'is_stripprefix_enabled',
'last_online_at',
'is_migrated',
'custom_type',
'public_port_timeout',
];
protected $casts = [
'public_port_timeout' => 'integer',
];
protected static function booted()
{
@@ -20,7 +45,7 @@ class ServiceDatabase extends BaseModel
});
static::saving(function ($service) {
if ($service->isDirty('status')) {
$service->forceFill(['last_online_at' => now()]);
$service->last_online_at = now();
}
});
}
@@ -124,7 +149,7 @@ class ServiceDatabase extends BaseModel
public function team()
{
return data_get($this, 'environment.project.team');
return data_get($this, 'service.environment.project.team');
}
public function workdir()
+35 -1
View File
@@ -2,17 +2,46 @@
namespace App\Models;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class SharedEnvironmentVariable extends Model
{
protected $guarded = [];
protected $fillable = [
// Core identification
'key',
'value',
'comment',
// Type and relationships
'type',
'team_id',
'project_id',
'environment_id',
'server_id',
// Boolean flags
'is_multiline',
'is_literal',
'is_shown_once',
// Metadata
'version',
];
protected $casts = [
'key' => 'string',
'value' => 'encrypted',
];
protected function key(): Attribute
{
return Attribute::make(
set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey($value),
);
}
public function team()
{
return $this->belongsTo(Team::class);
@@ -27,4 +56,9 @@ class SharedEnvironmentVariable extends Model
{
return $this->belongsTo(Environment::class);
}
public function server()
{
return $this->belongsTo(Server::class);
}
}
+2 -1
View File
@@ -24,7 +24,8 @@ class SlackNotificationSettings extends Model
'backup_failure_slack_notifications',
'scheduled_task_success_slack_notifications',
'scheduled_task_failure_slack_notifications',
'docker_cleanup_slack_notifications',
'docker_cleanup_success_slack_notifications',
'docker_cleanup_failure_slack_notifications',
'server_disk_usage_slack_notifications',
'server_reachable_slack_notifications',
'server_unreachable_slack_notifications',
+49 -5
View File
@@ -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,14 +12,56 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneClickhouse extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
protected $fillable = [
'uuid',
'name',
'description',
'clickhouse_admin_user',
'clickhouse_admin_password',
'is_log_drain_enabled',
'is_include_timestamps',
'status',
'image',
'is_public',
'public_port',
'ports_mappings',
'limits_memory',
'limits_memory_swap',
'limits_memory_swappiness',
'limits_memory_reservation',
'limits_cpus',
'limits_cpuset',
'limits_cpu_shares',
'started_at',
'restart_count',
'last_restart_at',
'last_restart_type',
'last_online_at',
'public_port_timeout',
'custom_docker_run_options',
'clickhouse_db',
'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 = [
'clickhouse_password' => 'encrypted',
'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',
'last_restart_at' => 'datetime',
'last_restart_type' => 'string',
@@ -43,7 +86,7 @@ class StandaloneClickhouse extends BaseModel
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
$database->forceFill(['last_online_at' => now()]);
$database->last_online_at = now();
}
});
}
@@ -79,6 +122,7 @@ class StandaloneClickhouse extends BaseModel
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');
@@ -134,7 +178,7 @@ class StandaloneClickhouse extends BaseModel
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
+35 -4
View File
@@ -3,26 +3,44 @@
namespace App\Models;
use App\Jobs\ConnectProxyToNetworksJob;
use App\Support\ValidationPatterns;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class StandaloneDocker extends BaseModel
{
use HasFactory;
use HasSafeStringAttribute;
protected $guarded = [];
protected $fillable = [
'server_id',
'name',
'network',
];
protected static function boot()
{
parent::boot();
static::created(function ($newStandaloneDocker) {
$server = $newStandaloneDocker->server;
$safeNetwork = escapeshellarg($newStandaloneDocker->network);
instant_remote_process([
"docker network inspect $newStandaloneDocker->network >/dev/null 2>&1 || docker network create --driver overlay --attachable $newStandaloneDocker->network >/dev/null",
"docker network inspect {$safeNetwork} >/dev/null 2>&1 || docker network create --driver overlay --attachable {$safeNetwork} >/dev/null",
], $server, false);
ConnectProxyToNetworksJob::dispatchSync($server);
});
}
public function setNetworkAttribute(string $value): void
{
if (! ValidationPatterns::isValidDockerNetwork($value)) {
throw new \InvalidArgumentException('Invalid Docker network name. Must start with alphanumeric and contain only alphanumeric characters, dots, hyphens, and underscores.');
}
$this->attributes['network'] = $value;
}
public function applications()
{
return $this->morphMany(Application::class, 'destination');
@@ -73,6 +91,16 @@ class StandaloneDocker extends BaseModel
return $this->belongsTo(Server::class);
}
public static function ownedByCurrentTeam()
{
return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id));
}
public static function ownedByCurrentTeamAPI(int $teamId)
{
return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId));
}
/**
* Get the server attribute using identity map caching.
* This intercepts lazy-loading to use cached Server lookups.
@@ -100,15 +128,18 @@ class StandaloneDocker extends BaseModel
return $this->morphMany(Service::class, 'destination');
}
public function databases()
public function databases(): Collection
{
$postgresqls = $this->postgresqls;
$redis = $this->redis;
$mongodbs = $this->mongodbs;
$mysqls = $this->mysqls;
$mariadbs = $this->mariadbs;
$keydbs = $this->keydbs;
$dragonflies = $this->dragonflies;
$clickhouses = $this->clickhouses;
return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs);
return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs)->concat($keydbs)->concat($dragonflies)->concat($clickhouses);
}
public function attachedTo()
+47 -4
View File
@@ -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,14 +12,55 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneDragonfly extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
protected $fillable = [
'uuid',
'name',
'description',
'dragonfly_password',
'is_log_drain_enabled',
'is_include_timestamps',
'status',
'image',
'is_public',
'public_port',
'ports_mappings',
'limits_memory',
'limits_memory_swap',
'limits_memory_swappiness',
'limits_memory_reservation',
'limits_cpus',
'limits_cpuset',
'limits_cpu_shares',
'started_at',
'restart_count',
'last_restart_at',
'last_restart_type',
'last_online_at',
'public_port_timeout',
'enable_ssl',
'custom_docker_run_options',
'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',
'last_restart_at' => 'datetime',
'last_restart_type' => 'string',
@@ -43,7 +85,7 @@ class StandaloneDragonfly extends BaseModel
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
$database->forceFill(['last_online_at' => now()]);
$database->last_online_at = now();
}
});
}
@@ -79,6 +121,7 @@ class StandaloneDragonfly extends BaseModel
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');
@@ -134,7 +177,7 @@ class StandaloneDragonfly extends BaseModel
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
+48 -4
View File
@@ -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,14 +12,56 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneKeydb extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
protected $fillable = [
'uuid',
'name',
'description',
'keydb_password',
'keydb_conf',
'is_log_drain_enabled',
'is_include_timestamps',
'status',
'image',
'is_public',
'public_port',
'ports_mappings',
'limits_memory',
'limits_memory_swap',
'limits_memory_swappiness',
'limits_memory_reservation',
'limits_cpus',
'limits_cpuset',
'limits_cpu_shares',
'started_at',
'restart_count',
'last_restart_at',
'last_restart_type',
'last_online_at',
'public_port_timeout',
'enable_ssl',
'custom_docker_run_options',
'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',
'last_restart_at' => 'datetime',
'last_restart_type' => 'string',
@@ -43,7 +86,7 @@ class StandaloneKeydb extends BaseModel
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
$database->forceFill(['last_online_at' => now()]);
$database->last_online_at = now();
}
});
}
@@ -79,6 +122,7 @@ class StandaloneKeydb extends BaseModel
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');
@@ -134,7 +178,7 @@ class StandaloneKeydb extends BaseModel
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
+50 -4
View File
@@ -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,14 +13,58 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMariadb extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
protected $fillable = [
'uuid',
'name',
'description',
'mariadb_root_password',
'mariadb_user',
'mariadb_password',
'mariadb_database',
'mariadb_conf',
'status',
'image',
'is_public',
'public_port',
'ports_mappings',
'limits_memory',
'limits_memory_swap',
'limits_memory_swappiness',
'limits_memory_reservation',
'limits_cpus',
'limits_cpuset',
'limits_cpu_shares',
'started_at',
'restart_count',
'last_restart_at',
'last_restart_type',
'last_online_at',
'public_port_timeout',
'enable_ssl',
'is_log_drain_enabled',
'custom_docker_run_options',
'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',
'last_restart_at' => 'datetime',
'last_restart_type' => 'string',
@@ -44,7 +89,7 @@ class StandaloneMariadb extends BaseModel
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
$database->forceFill(['last_online_at' => now()]);
$database->last_online_at = now();
}
});
}
@@ -80,6 +125,7 @@ class StandaloneMariadb extends BaseModel
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');
@@ -135,7 +181,7 @@ class StandaloneMariadb extends BaseModel
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
+51 -4
View File
@@ -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,13 +12,58 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMongodb extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
protected $fillable = [
'uuid',
'name',
'description',
'mongo_conf',
'mongo_initdb_root_username',
'mongo_initdb_root_password',
'mongo_initdb_database',
'status',
'image',
'is_public',
'public_port',
'ports_mappings',
'limits_memory',
'limits_memory_swap',
'limits_memory_swappiness',
'limits_memory_reservation',
'limits_cpus',
'limits_cpuset',
'limits_cpu_shares',
'started_at',
'restart_count',
'last_restart_at',
'last_restart_type',
'last_online_at',
'public_port_timeout',
'enable_ssl',
'ssl_mode',
'is_log_drain_enabled',
'is_include_timestamps',
'custom_docker_run_options',
'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',
'last_restart_type' => 'string',
@@ -49,7 +95,7 @@ class StandaloneMongodb extends BaseModel
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
$database->forceFill(['last_online_at' => now()]);
$database->last_online_at = now();
}
});
}
@@ -85,6 +131,7 @@ class StandaloneMongodb extends BaseModel
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');
@@ -140,7 +187,7 @@ class StandaloneMongodb extends BaseModel
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
+52 -4
View File
@@ -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,15 +12,61 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMysql extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
protected $fillable = [
'uuid',
'name',
'description',
'mysql_root_password',
'mysql_user',
'mysql_password',
'mysql_database',
'mysql_conf',
'status',
'image',
'is_public',
'public_port',
'ports_mappings',
'limits_memory',
'limits_memory_swap',
'limits_memory_swappiness',
'limits_memory_reservation',
'limits_cpus',
'limits_cpuset',
'limits_cpu_shares',
'started_at',
'restart_count',
'last_restart_at',
'last_restart_type',
'last_online_at',
'public_port_timeout',
'enable_ssl',
'ssl_mode',
'is_log_drain_enabled',
'is_include_timestamps',
'custom_docker_run_options',
'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',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
'last_restart_type' => 'string',
@@ -44,7 +91,7 @@ class StandaloneMysql extends BaseModel
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
$database->forceFill(['last_online_at' => now()]);
$database->last_online_at = now();
}
});
}
@@ -80,6 +127,7 @@ class StandaloneMysql extends BaseModel
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');
@@ -135,7 +183,7 @@ class StandaloneMysql extends BaseModel
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
+54 -4
View File
@@ -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,15 +12,63 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandalonePostgresql extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
protected $fillable = [
'uuid',
'name',
'description',
'postgres_user',
'postgres_password',
'postgres_db',
'postgres_initdb_args',
'postgres_host_auth_method',
'postgres_conf',
'init_scripts',
'status',
'image',
'is_public',
'public_port',
'ports_mappings',
'limits_memory',
'limits_memory_swap',
'limits_memory_swappiness',
'limits_memory_reservation',
'limits_cpus',
'limits_cpuset',
'limits_cpu_shares',
'started_at',
'restart_count',
'last_restart_at',
'last_restart_type',
'last_online_at',
'public_port_timeout',
'enable_ssl',
'ssl_mode',
'is_log_drain_enabled',
'is_include_timestamps',
'custom_docker_run_options',
'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',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
'last_restart_type' => 'string',
@@ -58,7 +107,7 @@ class StandalonePostgresql extends BaseModel
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
$database->forceFill(['last_online_at' => now()]);
$database->last_online_at = now();
}
});
}
@@ -113,13 +162,14 @@ class StandalonePostgresql extends BaseModel
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
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');
+47 -4
View File
@@ -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,13 +12,54 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneRedis extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
protected $fillable = [
'uuid',
'name',
'description',
'redis_conf',
'status',
'image',
'is_public',
'public_port',
'ports_mappings',
'limits_memory',
'limits_memory_swap',
'limits_memory_swappiness',
'limits_memory_reservation',
'limits_cpus',
'limits_cpuset',
'limits_cpu_shares',
'started_at',
'restart_count',
'last_restart_at',
'last_restart_type',
'last_online_at',
'public_port_timeout',
'enable_ssl',
'is_log_drain_enabled',
'is_include_timestamps',
'custom_docker_run_options',
'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',
'last_restart_type' => 'string',
@@ -42,7 +84,7 @@ class StandaloneRedis extends BaseModel
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
$database->forceFill(['last_online_at' => now()]);
$database->last_online_at = now();
}
});
@@ -84,6 +126,7 @@ class StandaloneRedis extends BaseModel
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');
@@ -139,7 +182,7 @@ class StandaloneRedis extends BaseModel
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
+34 -1
View File
@@ -6,13 +6,46 @@ use Illuminate\Database\Eloquent\Model;
class Subscription extends Model
{
protected $guarded = [];
protected $fillable = [
'team_id',
'stripe_invoice_paid',
'stripe_subscription_id',
'stripe_customer_id',
'stripe_cancel_at_period_end',
'stripe_plan_id',
'stripe_feedback',
'stripe_comment',
'stripe_trial_already_ended',
'stripe_past_due',
'stripe_refunded_at',
];
protected function casts(): array
{
return [
'stripe_refunded_at' => 'datetime',
];
}
public function team()
{
return $this->belongsTo(Team::class);
}
public function billingInterval(): string
{
if ($this->stripe_plan_id) {
$configKey = collect(config('subscription'))
->search($this->stripe_plan_id);
if ($configKey && str($configKey)->contains('yearly')) {
return 'yearly';
}
}
return 'monthly';
}
public function type()
{
if (isStripe()) {
+26 -1
View File
@@ -2,9 +2,24 @@
namespace App\Models;
use App\Support\ValidationPatterns;
class SwarmDocker extends BaseModel
{
protected $guarded = [];
protected $fillable = [
'server_id',
'name',
'network',
];
public function setNetworkAttribute(string $value): void
{
if (! ValidationPatterns::isValidDockerNetwork($value)) {
throw new \InvalidArgumentException('Invalid Docker network name. Must start with alphanumeric and contain only alphanumeric characters, dots, hyphens, and underscores.');
}
$this->attributes['network'] = $value;
}
public function applications()
{
@@ -56,6 +71,16 @@ class SwarmDocker extends BaseModel
return $this->belongsTo(Server::class);
}
public static function ownedByCurrentTeam()
{
return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id));
}
public static function ownedByCurrentTeamAPI(int $teamId)
{
return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId));
}
/**
* Get the server attribute using identity map caching.
* This intercepts lazy-loading to use cached Server lookups.
+4 -1
View File
@@ -8,7 +8,10 @@ class Tag extends BaseModel
{
use HasSafeStringAttribute;
protected $guarded = [];
protected $fillable = [
'name',
'team_id',
];
protected function customizeName($value)
{
+52 -22
View File
@@ -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;
@@ -40,7 +41,13 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
{
use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable;
protected $guarded = [];
protected $fillable = [
'name',
'description',
'personal_team',
'show_boarding',
'custom_server_limit',
];
protected $casts = [
'personal_team' => 'boolean',
@@ -65,34 +72,45 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
}
});
static::deleting(function ($team) {
$keys = $team->privateKeys;
foreach ($keys as $key) {
static::deleting(function (Team $team) {
RevokeUserTeamTokens::forTeam($team->id);
foreach ($team->privateKeys as $key) {
$key->delete();
}
$sources = $team->sources();
foreach ($sources as $source) {
// Transfer instance-wide sources to root team so they remain available
GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]);
GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]);
// Delete non-instance-wide sources owned by this team
$teamSources = GithubApp::where('team_id', $team->id)->get()
->merge(GitlabApp::where('team_id', $team->id)->get());
foreach ($teamSources as $source) {
$source->delete();
}
$tags = Tag::whereTeamId($team->id)->get();
foreach ($tags as $tag) {
foreach (Tag::whereTeamId($team->id)->get() as $tag) {
$tag->delete();
}
$shared_variables = $team->environment_variables();
foreach ($shared_variables as $shared_variable) {
$shared_variable->delete();
foreach ($team->environment_variables()->get() as $sharedVariable) {
$sharedVariable->delete();
}
$s3s = $team->s3s;
foreach ($s3s as $s3) {
foreach ($team->s3s as $s3) {
$s3->delete();
}
});
}
public static function serverLimitReached()
public static function serverLimitReached(?Team $team = null)
{
$serverLimit = Team::serverLimit();
$team = currentTeam();
$team = $team ?? currentTeam();
if (! $team) {
return true;
}
$serverLimit = Team::serverLimit($team);
$servers = $team->servers->count();
return $servers >= $serverLimit;
@@ -109,19 +127,23 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
public function serverOverflow()
{
if ($this->serverLimit() < $this->servers->count()) {
if (Team::serverLimit($this) < $this->servers->count()) {
return true;
}
return false;
}
public static function serverLimit()
public static function serverLimit(?Team $team = null)
{
if (currentTeam()->id === 0 && isDev()) {
$team = $team ?? currentTeam();
if (! $team) {
return 0;
}
if ($team->id === 0 && isDev()) {
return 9999999;
}
$team = Team::find(currentTeam()->id);
$team = Team::find($team->id);
if (! $team) {
return 0;
}
@@ -191,11 +213,16 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
$this->getNotificationSettings('discord')?->isEnabled() ||
$this->getNotificationSettings('slack')?->isEnabled() ||
$this->getNotificationSettings('telegram')?->isEnabled() ||
$this->getNotificationSettings('pushover')?->isEnabled();
$this->getNotificationSettings('pushover')?->isEnabled() ||
$this->getNotificationSettings('webhook')?->isEnabled();
}
public function subscriptionEnded()
{
if (! $this->subscription) {
return;
}
$this->subscription->update([
'stripe_subscription_id' => null,
'stripe_cancel_at_period_end' => false,
@@ -209,12 +236,15 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
'is_reachable' => false,
]);
ServerReachabilityChanged::dispatch($server);
$server->unreachable_count = 3;
$server->unreachable_notification_sent = true;
$server->save();
}
}
public function environment_variables()
{
return $this->hasMany(SharedEnvironmentVariable::class)->whereNull('project_id')->whereNull('environment_id');
return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'team');
}
public function members()
+4 -2
View File
@@ -25,7 +25,8 @@ class TelegramNotificationSettings extends Model
'backup_failure_telegram_notifications',
'scheduled_task_success_telegram_notifications',
'scheduled_task_failure_telegram_notifications',
'docker_cleanup_telegram_notifications',
'docker_cleanup_success_telegram_notifications',
'docker_cleanup_failure_telegram_notifications',
'server_disk_usage_telegram_notifications',
'server_reachable_telegram_notifications',
'server_unreachable_telegram_notifications',
@@ -39,7 +40,8 @@ class TelegramNotificationSettings extends Model
'telegram_notifications_backup_failure_thread_id',
'telegram_notifications_scheduled_task_success_thread_id',
'telegram_notifications_scheduled_task_failure_thread_id',
'telegram_notifications_docker_cleanup_thread_id',
'telegram_notifications_docker_cleanup_success_thread_id',
'telegram_notifications_docker_cleanup_failure_thread_id',
'telegram_notifications_server_disk_usage_thread_id',
'telegram_notifications_server_reachable_thread_id',
'telegram_notifications_server_unreachable_thread_id',
+43 -10
View File
@@ -2,9 +2,12 @@
namespace App\Models;
use App\Actions\User\RevokeUserTeamTokens;
use App\Jobs\UpdateStripeCustomerEmailJob;
use App\Notifications\Channels\SendsEmail;
use App\Notifications\TransactionalEmails\EmailChangeVerification;
use App\Notifications\TransactionalEmails\ResetPassword as TransactionalEmailsResetPassword;
use App\Services\ChangelogService;
use App\Traits\DeletesUserSessions;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -41,7 +44,16 @@ class User extends Authenticatable implements SendsEmail
{
use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
protected $guarded = [];
protected $fillable = [
'name',
'email',
'password',
'force_password_reset',
'marketing_emails',
'pending_email',
'email_change_code',
'email_change_code_expires_at',
];
protected $hidden = [
'password',
@@ -87,12 +99,31 @@ class User extends Authenticatable implements SendsEmail
$team['id'] = 0;
$team['name'] = 'Root Team';
}
$new_team = Team::create($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;
@@ -130,6 +161,7 @@ class User extends Authenticatable implements SendsEmail
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);
@@ -190,7 +222,8 @@ class User extends Authenticatable implements SendsEmail
$team['id'] = 0;
$team['name'] = 'Root Team';
}
$new_team = Team::create($team);
$new_team = (new Team)->forceFill($team);
$new_team->save();
$this->teams()->attach($new_team, ['role' => 'owner']);
return $new_team;
@@ -228,7 +261,7 @@ class User extends Authenticatable implements SendsEmail
public function getUnreadChangelogCount(): int
{
return app(\App\Services\ChangelogService::class)->getUnreadCountForUser($this);
return app(ChangelogService::class)->getUnreadCountForUser($this);
}
public function getRecipients(): array
@@ -239,12 +272,12 @@ class User extends Authenticatable implements SendsEmail
public function sendVerificationEmail()
{
$mail = new MailMessage;
$url = Url::temporarySignedRoute(
$url = URL::temporarySignedRoute(
'verify.verify',
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
[
'id' => $this->getKey(),
'hash' => sha1($this->getEmailForVerification()),
'hash' => hash('sha256', $this->getEmailForVerification()),
]
);
$mail->view('emails.email-verification', [
@@ -395,20 +428,20 @@ class User extends Authenticatable implements SendsEmail
public function requestEmailChange(string $newEmail): void
{
// Generate 6-digit code
$code = sprintf('%06d', mt_rand(0, 999999));
$code = sprintf('%06d', random_int(0, 999999));
// Set expiration using config value
$expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10);
$expiresAt = Carbon::now()->addMinutes($expiryMinutes);
$this->update([
$this->fill([
'pending_email' => $newEmail,
'email_change_code' => $code,
'email_change_code_expires_at' => $expiresAt,
]);
])->save();
// Send verification email to new address
$this->notify(new \App\Notifications\TransactionalEmails\EmailChangeVerification($this, $code, $newEmail, $expiresAt));
$this->notify(new EmailChangeVerification($this, $code, $newEmail, $expiresAt));
}
public function isEmailChangeCodeValid(string $code): bool