diff --git a/app/Events/RestoreJobFinished.php b/app/Events/RestoreJobFinished.php index d3adb7798..8690e01f6 100644 --- a/app/Events/RestoreJobFinished.php +++ b/app/Events/RestoreJobFinished.php @@ -17,17 +17,23 @@ public function __construct($data) $tmpPath = data_get($data, 'tmpPath'); $container = data_get($data, 'container'); $serverId = data_get($data, 'serverId'); - if (filled($scriptPath) && filled($tmpPath) && filled($container) && filled($serverId)) { - if (str($tmpPath)->startsWith('/tmp/') - && str($scriptPath)->startsWith('/tmp/') - && ! str($tmpPath)->contains('..') - && ! str($scriptPath)->contains('..') - && strlen($tmpPath) > 5 // longer than just "/tmp/" - && strlen($scriptPath) > 5 - ) { - $commands[] = "docker exec {$container} sh -c 'rm {$scriptPath}'"; - $commands[] = "docker exec {$container} sh -c 'rm {$tmpPath}'"; - instant_remote_process($commands, Server::find($serverId), throwError: true); + + if (filled($container) && filled($serverId)) { + $commands = []; + + if (isSafeTmpPath($scriptPath)) { + $commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($scriptPath)." 2>/dev/null || true'"; + } + + if (isSafeTmpPath($tmpPath)) { + $commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($tmpPath)." 2>/dev/null || true'"; + } + + if (! empty($commands)) { + $server = Server::find($serverId); + if ($server) { + instant_remote_process($commands, $server, throwError: false); + } } } } diff --git a/app/Events/S3RestoreJobFinished.php b/app/Events/S3RestoreJobFinished.php new file mode 100644 index 000000000..e1f844558 --- /dev/null +++ b/app/Events/S3RestoreJobFinished.php @@ -0,0 +1,56 @@ +/dev/null || true'; + } + + // Clean up server temp file if still exists (should already be cleaned) + if (isSafeTmpPath($serverTmpPath)) { + $commands[] = 'rm -f '.escapeshellarg($serverTmpPath).' 2>/dev/null || true'; + } + + // Clean up any remaining files in database container (may already be cleaned) + if (filled($container)) { + if (isSafeTmpPath($containerTmpPath)) { + $commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($containerTmpPath).' 2>/dev/null || true'; + } + if (isSafeTmpPath($scriptPath)) { + $commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($scriptPath).' 2>/dev/null || true'; + } + } + + if (! empty($commands)) { + $server = Server::find($serverId); + if ($server) { + instant_remote_process($commands, $server, throwError: false); + } + } + } + } +} diff --git a/app/Jobs/CoolifyTask.php b/app/Jobs/CoolifyTask.php index d6dc6fa05..ce535e036 100755 --- a/app/Jobs/CoolifyTask.php +++ b/app/Jobs/CoolifyTask.php @@ -90,5 +90,22 @@ public function failed(?\Throwable $exception): void 'failed_at' => now()->toIso8601String(), ]); $this->activity->save(); + + // Dispatch cleanup event on failure (same as on success) + if ($this->call_event_on_finish) { + try { + $eventClass = "App\\Events\\$this->call_event_on_finish"; + if (! is_null($this->call_event_data)) { + event(new $eventClass($this->call_event_data)); + } else { + event(new $eventClass($this->activity->causer_id)); + } + Log::info('Cleanup event dispatched after job failure', [ + 'event' => $this->call_event_on_finish, + ]); + } catch (\Throwable $e) { + Log::error('Error dispatching cleanup event on failure: '.$e->getMessage()); + } + } } } diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 8766a1afc..45ac6eb7d 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -639,7 +639,13 @@ private function upload_to_s3(): void } else { $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}"; } - $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\""; + + // Escape S3 credentials to prevent command injection + $escapedEndpoint = escapeshellarg($endpoint); + $escapedKey = escapeshellarg($key); + $escapedSecret = escapeshellarg($secret); + + $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/"; instant_remote_process($commands, $this->server); diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 7d6ac3131..fd191e587 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Database; +use App\Models\S3Storage; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; @@ -12,6 +13,92 @@ class Import extends Component { use AuthorizesRequests; + /** + * Validate that a string is safe for use as an S3 bucket name. + * Allows alphanumerics, dots, dashes, and underscores. + */ + private function validateBucketName(string $bucket): bool + { + return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1; + } + + /** + * Validate that a string is safe for use as an S3 path. + * Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters. + */ + private function validateS3Path(string $path): bool + { + // Must not be empty + if (empty($path)) { + return false; + } + + // Must not contain dangerous shell metacharacters or command injection patterns + $dangerousPatterns = [ + '..', // Directory traversal + '$(', // Command substitution + '`', // Backtick command substitution + '|', // Pipe + ';', // Command separator + '&', // Background/AND + '>', // Redirect + '<', // Redirect + "\n", // Newline + "\r", // Carriage return + "\0", // Null byte + "'", // Single quote + '"', // Double quote + '\\', // Backslash + ]; + + foreach ($dangerousPatterns as $pattern) { + if (str_contains($path, $pattern)) { + return false; + } + } + + // Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at + return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1; + } + + /** + * Validate that a string is safe for use as a file path on the server. + */ + private function validateServerPath(string $path): bool + { + // Must be an absolute path + if (! str_starts_with($path, '/')) { + return false; + } + + // Must not contain dangerous shell metacharacters or command injection patterns + $dangerousPatterns = [ + '..', // Directory traversal + '$(', // Command substitution + '`', // Backtick command substitution + '|', // Pipe + ';', // Command separator + '&', // Background/AND + '>', // Redirect + '<', // Redirect + "\n", // Newline + "\r", // Carriage return + "\0", // Null byte + "'", // Single quote + '"', // Double quote + '\\', // Backslash + ]; + + foreach ($dangerousPatterns as $pattern) { + if (str_contains($path, $pattern)) { + return false; + } + } + + // Allow alphanumerics, dots, dashes, underscores, slashes, and spaces + return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1; + } + public bool $unsupported = false; public $resource; @@ -54,6 +141,15 @@ class Import extends Component public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='; + // S3 Restore properties + public $availableS3Storages = []; + + public ?int $s3StorageId = null; + + public string $s3Path = ''; + + public ?int $s3FileSize = null; + public function getListeners() { $userId = Auth::id(); @@ -65,11 +161,9 @@ public function getListeners() public function mount() { - if (isDev()) { - $this->customLocation = '/data/coolify/pg-dump-all-1736245863.gz'; - } $this->parameters = get_route_parameters(); $this->getContainers(); + $this->loadAvailableS3Storages(); } public function updatedDumpAll($value) @@ -152,8 +246,16 @@ public function getContainers() public function checkFile() { if (filled($this->customLocation)) { + // Validate the custom location to prevent command injection + if (! $this->validateServerPath($this->customLocation)) { + $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); + + return; + } + try { - $result = instant_remote_process(["ls -l {$this->customLocation}"], $this->server, throwError: false); + $escapedPath = escapeshellarg($this->customLocation); + $result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false); if (blank($result)) { $this->dispatch('error', 'The file does not exist or has been deleted.'); @@ -179,59 +281,35 @@ public function runImport() try { $this->importRunning = true; $this->importCommands = []; - if (filled($this->customLocation)) { - $backupFileName = '/tmp/restore_'.$this->resource->uuid; - $this->importCommands[] = "docker cp {$this->customLocation} {$this->container}:{$backupFileName}"; - $tmpPath = $backupFileName; - } else { - $backupFileName = "upload/{$this->resource->uuid}/restore"; - $path = Storage::path($backupFileName); - if (! Storage::exists($backupFileName)) { - $this->dispatch('error', 'The file does not exist or has been deleted.'); + $backupFileName = "upload/{$this->resource->uuid}/restore"; - return; - } + // Check if an uploaded file exists first (takes priority over custom location) + if (Storage::exists($backupFileName)) { + $path = Storage::path($backupFileName); $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid; instant_scp($path, $tmpPath, $this->server); Storage::delete($backupFileName); $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}"; + } elseif (filled($this->customLocation)) { + // Validate the custom location to prevent command injection + if (! $this->validateServerPath($this->customLocation)) { + $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.'); + + return; + } + $tmpPath = '/tmp/restore_'.$this->resource->uuid; + $escapedCustomLocation = escapeshellarg($this->customLocation); + $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}"; + } else { + $this->dispatch('error', 'The file does not exist or has been deleted.'); + + return; } // Copy the restore command to a script file $scriptPath = "/tmp/restore_{$this->resource->uuid}.sh"; - switch ($this->resource->getMorphClass()) { - case \App\Models\StandaloneMariadb::class: - $restoreCommand = $this->mariadbRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD"; - } else { - $restoreCommand .= " < {$tmpPath}"; - } - break; - case \App\Models\StandaloneMysql::class: - $restoreCommand = $this->mysqlRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD"; - } else { - $restoreCommand .= " < {$tmpPath}"; - } - break; - case \App\Models\StandalonePostgresql::class: - $restoreCommand = $this->postgresqlRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres"; - } else { - $restoreCommand .= " {$tmpPath}"; - } - break; - case \App\Models\StandaloneMongodb::class: - $restoreCommand = $this->mongodbRestoreCommand; - if ($this->dumpAll === false) { - $restoreCommand .= "{$tmpPath}"; - } - break; - } + $restoreCommand = $this->buildRestoreCommand($tmpPath); $restoreCommandBase64 = base64_encode($restoreCommand); $this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}"; @@ -248,7 +326,10 @@ public function runImport() 'container' => $this->container, 'serverId' => $this->server->id, ]); + + // Dispatch activity to the monitor and open slide-over $this->dispatch('activityMonitor', $activity->id); + $this->dispatch('databaserestore'); } } catch (\Throwable $e) { return handleError($e, $this); @@ -257,4 +338,264 @@ public function runImport() $this->importCommands = []; } } + + public function loadAvailableS3Storages() + { + try { + $this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description']) + ->where('is_usable', true) + ->get(); + } catch (\Throwable $e) { + $this->availableS3Storages = collect(); + } + } + + public function updatedS3Path($value) + { + // Reset validation state when path changes + $this->s3FileSize = null; + + // Ensure path starts with a slash + if ($value !== null && $value !== '') { + $this->s3Path = str($value)->trim()->start('/')->value(); + } + } + + public function updatedS3StorageId() + { + // Reset validation state when storage changes + $this->s3FileSize = null; + } + + public function checkS3File() + { + if (! $this->s3StorageId) { + $this->dispatch('error', 'Please select an S3 storage.'); + + return; + } + + if (blank($this->s3Path)) { + $this->dispatch('error', 'Please provide an S3 path.'); + + return; + } + + // Clean the path (remove leading slash if present) + $cleanPath = ltrim($this->s3Path, '/'); + + // Validate the S3 path early to prevent command injection in subsequent operations + if (! $this->validateS3Path($cleanPath)) { + $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); + + return; + } + + try { + $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); + + // Validate bucket name early + if (! $this->validateBucketName($s3Storage->bucket)) { + $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); + + return; + } + + // Test connection + $s3Storage->testConnection(); + + // Build S3 disk configuration + $disk = Storage::build([ + 'driver' => 's3', + 'region' => $s3Storage->region, + 'key' => $s3Storage->key, + 'secret' => $s3Storage->secret, + 'bucket' => $s3Storage->bucket, + 'endpoint' => $s3Storage->endpoint, + 'use_path_style_endpoint' => true, + ]); + + // Check if file exists + if (! $disk->exists($cleanPath)) { + $this->dispatch('error', 'File not found in S3. Please check the path.'); + + return; + } + + // Get file size + $this->s3FileSize = $disk->size($cleanPath); + + $this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize)); + } catch (\Throwable $e) { + $this->s3FileSize = null; + + return handleError($e, $this); + } + } + + public function restoreFromS3() + { + $this->authorize('update', $this->resource); + + if (! $this->s3StorageId || blank($this->s3Path)) { + $this->dispatch('error', 'Please select S3 storage and provide a path first.'); + + return; + } + + if (is_null($this->s3FileSize)) { + $this->dispatch('error', 'Please check the file first by clicking "Check File".'); + + return; + } + + try { + $this->importRunning = true; + + $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); + + $key = $s3Storage->key; + $secret = $s3Storage->secret; + $bucket = $s3Storage->bucket; + $endpoint = $s3Storage->endpoint; + + // Validate bucket name to prevent command injection + if (! $this->validateBucketName($bucket)) { + $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); + + return; + } + + // Clean the S3 path + $cleanPath = ltrim($this->s3Path, '/'); + + // Validate the S3 path to prevent command injection + if (! $this->validateS3Path($cleanPath)) { + $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); + + return; + } + + // Get helper image + $helperImage = config('constants.coolify.helper_image'); + $latestVersion = getHelperVersion(); + $fullImageName = "{$helperImage}:{$latestVersion}"; + + // Get the database destination network + $destinationNetwork = $this->resource->destination->network ?? 'coolify'; + + // Generate unique names for this operation + $containerName = "s3-restore-{$this->resource->uuid}"; + $helperTmpPath = '/tmp/'.basename($cleanPath); + $serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath); + $containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath); + $scriptPath = "/tmp/restore_{$this->resource->uuid}.sh"; + + // Prepare all commands in sequence + $commands = []; + + // 1. Clean up any existing helper container and temp files from previous runs + $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; + $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true"; + $commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true"; + + // 2. Start helper container on the database network + $commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600"; + + // 3. Configure S3 access in helper container + $escapedEndpoint = escapeshellarg($endpoint); + $escapedKey = escapeshellarg($key); + $escapedSecret = escapeshellarg($secret); + $commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; + + // 4. Check file exists in S3 (bucket and path already validated above) + $escapedBucket = escapeshellarg($bucket); + $escapedCleanPath = escapeshellarg($cleanPath); + $escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}"); + $commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}"; + + // 5. Download from S3 to helper container (progress shown by default) + $escapedHelperTmpPath = escapeshellarg($helperTmpPath); + $commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}"; + + // 6. Copy from helper to server, then immediately to database container + $commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}"; + $commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}"; + + // 7. Cleanup helper container and server temp file immediately (no longer needed) + $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; + $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true"; + + // 8. Build and execute restore command inside database container + $restoreCommand = $this->buildRestoreCommand($containerTmpPath); + + $restoreCommandBase64 = base64_encode($restoreCommand); + $commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}"; + $commands[] = "chmod +x {$scriptPath}"; + $commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}"; + + // 9. Execute restore and cleanup temp files immediately after completion + $commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'"; + $commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; + + // Execute all commands with cleanup event (as safety net for edge cases) + $activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [ + 'containerName' => $containerName, + 'serverTmpPath' => $serverTmpPath, + 'scriptPath' => $scriptPath, + 'containerTmpPath' => $containerTmpPath, + 'container' => $this->container, + 'serverId' => $this->server->id, + ]); + + // Dispatch activity to the monitor and open slide-over + $this->dispatch('activityMonitor', $activity->id); + $this->dispatch('databaserestore'); + $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); + } catch (\Throwable $e) { + $this->importRunning = false; + + return handleError($e, $this); + } + } + + public function buildRestoreCommand(string $tmpPath): string + { + switch ($this->resource->getMorphClass()) { + case \App\Models\StandaloneMariadb::class: + $restoreCommand = $this->mariadbRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD"; + } else { + $restoreCommand .= " < {$tmpPath}"; + } + break; + case \App\Models\StandaloneMysql::class: + $restoreCommand = $this->mysqlRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD"; + } else { + $restoreCommand .= " < {$tmpPath}"; + } + break; + case \App\Models\StandalonePostgresql::class: + $restoreCommand = $this->postgresqlRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres"; + } else { + $restoreCommand .= " {$tmpPath}"; + } + break; + case \App\Models\StandaloneMongodb::class: + $restoreCommand = $this->mongodbRestoreCommand; + if ($this->dumpAll === false) { + $restoreCommand .= "{$tmpPath}"; + } + break; + default: + $restoreCommand = ''; + } + + return $restoreCommand; + } } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index 5f5e12e0a..fa65e8bd2 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -2,8 +2,11 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\Environment; +use App\Models\Project; use App\Traits\EnvironmentVariableAnalyzer; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Computed; use Livewire\Component; class Add extends Component @@ -56,6 +59,72 @@ public function mount() $this->problematicVariables = self::getProblematicVariablesForFrontend(); } + #[Computed] + public function availableSharedVariables(): array + { + $team = currentTeam(); + $result = [ + 'team' => [], + 'project' => [], + 'environment' => [], + ]; + + // Early return if no team + if (! $team) { + return $result; + } + + // Check if user can view team variables + try { + $this->authorize('view', $team); + $result['team'] = $team->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view team variables + } + + // Get project variables if we have a project_uuid in route + $projectUuid = data_get($this->parameters, 'project_uuid'); + if ($projectUuid) { + $project = Project::where('team_id', $team->id) + ->where('uuid', $projectUuid) + ->first(); + + if ($project) { + try { + $this->authorize('view', $project); + $result['project'] = $project->environment_variables() + ->pluck('key') + ->toArray(); + + // Get environment variables if we have an environment_uuid in route + $environmentUuid = data_get($this->parameters, 'environment_uuid'); + if ($environmentUuid) { + $environment = $project->environments() + ->where('uuid', $environmentUuid) + ->first(); + + if ($environment) { + try { + $this->authorize('view', $environment); + $result['environment'] = $environment->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view environment variables + } + } + } + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view project variables + } + } + } + + return $result; + } + public function submit() { $this->validate(); diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 96f13b173..7a96eabb2 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -44,6 +44,8 @@ class Index extends Component public bool $forceSaveDomains = false; + public $buildActivityId = null; + public function render() { return view('livewire.settings.index'); @@ -151,4 +153,37 @@ public function submit() return handleError($e, $this); } } + + public function buildHelperImage() + { + try { + if (! isDev()) { + $this->dispatch('error', 'Building helper image is only available in development mode.'); + + return; + } + + $version = $this->dev_helper_version ?: config('constants.coolify.helper_version'); + if (empty($version)) { + $this->dispatch('error', 'Please specify a version to build.'); + + return; + } + + $buildCommand = "docker build -t ghcr.io/coollabsio/coolify-helper:{$version} -f docker/coolify-helper/Dockerfile ."; + + $activity = remote_process( + command: [$buildCommand], + server: $this->server, + type: 'build-helper-image' + ); + + $this->buildActivityId = $activity->id; + $this->dispatch('activityMonitor', $activity->id); + + $this->dispatch('success', "Building coolify-helper:{$version}..."); + } catch (\Exception $e) { + return handleError($e, $this); + } + } } diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index d97550693..d101d7b58 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -120,9 +120,16 @@ public function testConnection() $this->storage->testConnection(shouldSave: true); + // Update component property to reflect the new validation status + $this->isUsable = $this->storage->is_usable; + return $this->dispatch('success', 'Connection is working.', 'Tested with "ListObjectsV2" action.'); } catch (\Throwable $e) { - $this->dispatch('error', 'Failed to create storage.', $e->getMessage()); + // Refresh model and sync to get the latest state + $this->storage->refresh(); + $this->isUsable = $this->storage->is_usable; + + $this->dispatch('error', 'Failed to test connection.', $e->getMessage()); } } diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php index bdea9a3b0..fdf3d0d28 100644 --- a/app/Livewire/Storage/Show.php +++ b/app/Livewire/Storage/Show.php @@ -3,10 +3,13 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public $storage = null; public function mount() @@ -15,6 +18,7 @@ public function mount() if (! $this->storage) { abort(404); } + $this->authorize('view', $this->storage); } public function render() diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index de27bbca6..47652eb35 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -3,6 +3,7 @@ namespace App\Models; 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; @@ -41,6 +42,19 @@ public function awsUrl() return "{$this->endpoint}/{$this->bucket}"; } + protected function path(): Attribute + { + return Attribute::make( + set: function (?string $value) { + if ($value === null || $value === '') { + return null; + } + + return str($value)->trim()->start('/')->value(); + } + ); + } + public function testConnection(bool $shouldSave = false) { try { diff --git a/app/Models/WebhookNotificationSettings.php b/app/Models/WebhookNotificationSettings.php index 79bd0ae62..731006181 100644 --- a/app/Models/WebhookNotificationSettings.php +++ b/app/Models/WebhookNotificationSettings.php @@ -24,7 +24,8 @@ class WebhookNotificationSettings extends Model 'backup_failure_webhook_notifications', 'scheduled_task_success_webhook_notifications', 'scheduled_task_failure_webhook_notifications', - 'docker_cleanup_webhook_notifications', + 'docker_cleanup_success_webhook_notifications', + 'docker_cleanup_failure_webhook_notifications', 'server_disk_usage_webhook_notifications', 'server_reachable_webhook_notifications', 'server_unreachable_webhook_notifications', @@ -45,7 +46,8 @@ protected function casts(): array 'backup_failure_webhook_notifications' => 'boolean', 'scheduled_task_success_webhook_notifications' => 'boolean', 'scheduled_task_failure_webhook_notifications' => 'boolean', - 'docker_cleanup_webhook_notifications' => 'boolean', + 'docker_cleanup_success_webhook_notifications' => 'boolean', + 'docker_cleanup_failure_webhook_notifications' => 'boolean', 'server_disk_usage_webhook_notifications' => 'boolean', 'server_reachable_webhook_notifications' => 'boolean', 'server_unreachable_webhook_notifications' => 'boolean', diff --git a/app/Policies/InstanceSettingsPolicy.php b/app/Policies/InstanceSettingsPolicy.php new file mode 100644 index 000000000..a04f07a28 --- /dev/null +++ b/app/Policies/InstanceSettingsPolicy.php @@ -0,0 +1,25 @@ + \App\Policies\ApiTokenPolicy::class, + // Instance settings policy + \App\Models\InstanceSettings::class => \App\Policies\InstanceSettingsPolicy::class, + // Team policy \App\Models\Team::class => \App\Policies\TeamPolicy::class, diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php new file mode 100644 index 000000000..7cf8ee8fa --- /dev/null +++ b/app/View/Components/Forms/EnvVarInput.php @@ -0,0 +1,89 @@ +canGate && $this->canResource && $this->autoDisable) { + $hasPermission = Gate::allows($this->canGate, $this->canResource); + + if (! $hasPermission) { + $this->disabled = true; + } + } + } + + public function render(): View|Closure|string + { + // Store original ID for wire:model binding (property name) + $this->modelBinding = $this->id; + + if (is_null($this->id)) { + $this->id = new Cuid2; + // Don't create wire:model binding for auto-generated IDs + $this->modelBinding = 'null'; + } + // Generate unique HTML ID by adding random suffix + // This prevents duplicate IDs when multiple forms are on the same page + if ($this->modelBinding && $this->modelBinding !== 'null') { + // Use original ID with random suffix for uniqueness + $uniqueSuffix = new Cuid2; + $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; + } else { + $this->htmlId = (string) $this->id; + } + + if (is_null($this->name)) { + $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id; + } + + $this->scopeUrls = [ + 'team' => route('shared-variables.team.index'), + 'project' => route('shared-variables.project.index'), + 'environment' => $this->projectUuid && $this->environmentUuid + ? route('shared-variables.environment.show', [ + 'project_uuid' => $this->projectUuid, + 'environment_uuid' => $this->environmentUuid, + ]) + : route('shared-variables.environment.index'), + 'default' => route('shared-variables.index'), + ]; + + return view('components.forms.env-var-input'); + } +} diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 8a278476e..1b23247fa 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3154,6 +3154,118 @@ function generateDockerComposeServiceName(mixed $services, int $pullRequestId = return $collection; } +function formatBytes(?int $bytes, int $precision = 2): string +{ + if ($bytes === null || $bytes === 0) { + return '0 B'; + } + + // Handle negative numbers + if ($bytes < 0) { + return '0 B'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + $base = 1024; + $exponent = floor(log($bytes) / log($base)); + $exponent = min($exponent, count($units) - 1); + + $value = $bytes / pow($base, $exponent); + + return round($value, $precision).' '.$units[$exponent]; +} + +/** + * Validates that a file path is safely within the /tmp/ directory. + * Protects against path traversal attacks by resolving the real path + * and verifying it stays within /tmp/. + * + * Note: On macOS, /tmp is often a symlink to /private/tmp, which is handled. + */ +function isSafeTmpPath(?string $path): bool +{ + if (blank($path)) { + return false; + } + + // URL decode to catch encoded traversal attempts + $decodedPath = urldecode($path); + + // Minimum length check - /tmp/x is 6 chars + if (strlen($decodedPath) < 6) { + return false; + } + + // Must start with /tmp/ + if (! str($decodedPath)->startsWith('/tmp/')) { + return false; + } + + // Quick check for obvious traversal attempts + if (str($decodedPath)->contains('..')) { + return false; + } + + // Check for null bytes (directory traversal technique) + if (str($decodedPath)->contains("\0")) { + return false; + } + + // Remove any trailing slashes for consistent validation + $normalizedPath = rtrim($decodedPath, '/'); + + // Normalize the path by removing redundant separators and resolving . and .. + // We'll do this manually since realpath() requires the path to exist + $parts = explode('/', $normalizedPath); + $resolvedParts = []; + + foreach ($parts as $part) { + if ($part === '' || $part === '.') { + // Skip empty parts (from //) and current directory references + continue; + } elseif ($part === '..') { + // Parent directory - this should have been caught earlier but double-check + return false; + } else { + $resolvedParts[] = $part; + } + } + + $resolvedPath = '/'.implode('/', $resolvedParts); + + // Final check: resolved path must start with /tmp/ + // And must have at least one component after /tmp/ + if (! str($resolvedPath)->startsWith('/tmp/') || $resolvedPath === '/tmp') { + return false; + } + + // Resolve the canonical /tmp path (handles symlinks like /tmp -> /private/tmp on macOS) + $canonicalTmpPath = realpath('/tmp'); + if ($canonicalTmpPath === false) { + // If /tmp doesn't exist, something is very wrong, but allow non-existing paths + $canonicalTmpPath = '/tmp'; + } + + // Calculate dirname once to avoid redundant calls + $dirPath = dirname($resolvedPath); + + // If the directory exists, resolve it via realpath to catch symlink attacks + if (is_dir($dirPath)) { + // For existing paths, resolve to absolute path to catch symlinks + $realDir = realpath($dirPath); + if ($realDir === false) { + return false; + } + + // Check if the real directory is within /tmp (or its canonical path) + if (! str($realDir)->startsWith('/tmp') && ! str($realDir)->startsWith($canonicalTmpPath)) { + return false; + } + } + + return true; +} + /** * Transform colon-delimited status format to human-readable parentheses format. * diff --git a/config/constants.php b/config/constants.php index 161d1e2fd..ca47367f4 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.446', + 'version' => '4.0.0-beta.447', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/database/migrations/2025_10_10_120001_populate_webhook_notification_settings_for_existing_teams.php b/database/migrations/2025_10_10_120001_populate_webhook_notification_settings_for_existing_teams.php deleted file mode 100644 index de2707557..000000000 --- a/database/migrations/2025_10_10_120001_populate_webhook_notification_settings_for_existing_teams.php +++ /dev/null @@ -1,47 +0,0 @@ -get(); - - foreach ($teams as $team) { - DB::table('webhook_notification_settings')->updateOrInsert( - ['team_id' => $team->id], - [ - 'webhook_enabled' => false, - 'webhook_url' => null, - 'deployment_success_webhook_notifications' => false, - 'deployment_failure_webhook_notifications' => true, - 'status_change_webhook_notifications' => false, - 'backup_success_webhook_notifications' => false, - 'backup_failure_webhook_notifications' => true, - 'scheduled_task_success_webhook_notifications' => false, - 'scheduled_task_failure_webhook_notifications' => true, - 'docker_cleanup_success_webhook_notifications' => false, - 'docker_cleanup_failure_webhook_notifications' => true, - 'server_disk_usage_webhook_notifications' => true, - 'server_reachable_webhook_notifications' => false, - 'server_unreachable_webhook_notifications' => true, - 'server_patch_webhook_notifications' => false, - ] - ); - } - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - // We don't need to do anything in down() since the webhook_notification_settings - // table will be dropped by the create migration's down() method - } -}; diff --git a/database/migrations/2025_10_10_120002_create_cloud_init_scripts_table.php b/database/migrations/2025_10_10_120002_create_cloud_init_scripts_table.php deleted file mode 100644 index ae63dc53a..000000000 --- a/database/migrations/2025_10_10_120002_create_cloud_init_scripts_table.php +++ /dev/null @@ -1,36 +0,0 @@ -id(); - $table->foreignId('team_id')->constrained()->onDelete('cascade'); - $table->string('name'); - $table->text('script'); // Encrypted in the model - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('cloud_init_scripts'); - } -}; diff --git a/database/migrations/2025_10_10_120002_create_webhook_notification_settings_table.php b/database/migrations/2025_10_10_120002_create_webhook_notification_settings_table.php deleted file mode 100644 index c0689f81e..000000000 --- a/database/migrations/2025_10_10_120002_create_webhook_notification_settings_table.php +++ /dev/null @@ -1,52 +0,0 @@ -id(); - $table->foreignId('team_id')->constrained()->cascadeOnDelete(); - - $table->boolean('webhook_enabled')->default(false); - $table->text('webhook_url')->nullable(); - - $table->boolean('deployment_success_webhook_notifications')->default(false); - $table->boolean('deployment_failure_webhook_notifications')->default(true); - $table->boolean('status_change_webhook_notifications')->default(false); - $table->boolean('backup_success_webhook_notifications')->default(false); - $table->boolean('backup_failure_webhook_notifications')->default(true); - $table->boolean('scheduled_task_success_webhook_notifications')->default(false); - $table->boolean('scheduled_task_failure_webhook_notifications')->default(true); - $table->boolean('docker_cleanup_success_webhook_notifications')->default(false); - $table->boolean('docker_cleanup_failure_webhook_notifications')->default(true); - $table->boolean('server_disk_usage_webhook_notifications')->default(true); - $table->boolean('server_reachable_webhook_notifications')->default(false); - $table->boolean('server_unreachable_webhook_notifications')->default(true); - $table->boolean('server_patch_webhook_notifications')->default(false); - - $table->unique(['team_id']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('webhook_notification_settings'); - } -}; diff --git a/database/migrations/2025_11_25_000001_create_webhook_notification_settings_table.php b/database/migrations/2025_11_25_000001_create_webhook_notification_settings_table.php new file mode 100644 index 000000000..df620bd6e --- /dev/null +++ b/database/migrations/2025_11_25_000001_create_webhook_notification_settings_table.php @@ -0,0 +1,89 @@ +id(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + + $table->boolean('webhook_enabled')->default(false); + $table->text('webhook_url')->nullable(); + + $table->boolean('deployment_success_webhook_notifications')->default(false); + $table->boolean('deployment_failure_webhook_notifications')->default(true); + $table->boolean('status_change_webhook_notifications')->default(false); + $table->boolean('backup_success_webhook_notifications')->default(false); + $table->boolean('backup_failure_webhook_notifications')->default(true); + $table->boolean('scheduled_task_success_webhook_notifications')->default(false); + $table->boolean('scheduled_task_failure_webhook_notifications')->default(true); + $table->boolean('docker_cleanup_success_webhook_notifications')->default(false); + $table->boolean('docker_cleanup_failure_webhook_notifications')->default(true); + $table->boolean('server_disk_usage_webhook_notifications')->default(true); + $table->boolean('server_reachable_webhook_notifications')->default(false); + $table->boolean('server_unreachable_webhook_notifications')->default(true); + $table->boolean('server_patch_webhook_notifications')->default(false); + $table->boolean('traefik_outdated_webhook_notifications')->default(true); + + $table->unique(['team_id']); + }); + } + + // Populate webhook notification settings for existing teams (only if they don't already have settings) + DB::table('teams')->chunkById(100, function ($teams) { + foreach ($teams as $team) { + try { + // Check if settings already exist for this team + $exists = DB::table('webhook_notification_settings') + ->where('team_id', $team->id) + ->exists(); + + if (! $exists) { + // Only insert if no settings exist - don't overwrite existing preferences + DB::table('webhook_notification_settings')->insert([ + 'team_id' => $team->id, + 'webhook_enabled' => false, + 'webhook_url' => null, + 'deployment_success_webhook_notifications' => false, + 'deployment_failure_webhook_notifications' => true, + 'status_change_webhook_notifications' => false, + 'backup_success_webhook_notifications' => false, + 'backup_failure_webhook_notifications' => true, + 'scheduled_task_success_webhook_notifications' => false, + 'scheduled_task_failure_webhook_notifications' => true, + 'docker_cleanup_success_webhook_notifications' => false, + 'docker_cleanup_failure_webhook_notifications' => true, + 'server_disk_usage_webhook_notifications' => true, + 'server_reachable_webhook_notifications' => false, + 'server_unreachable_webhook_notifications' => true, + 'server_patch_webhook_notifications' => false, + 'traefik_outdated_webhook_notifications' => true, + ]); + } + } catch (\Throwable $e) { + Log::error('Error creating webhook notification settings for team '.$team->id.': '.$e->getMessage()); + } + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_notification_settings'); + } +}; diff --git a/database/migrations/2025_11_25_000002_create_cloud_init_scripts_table.php b/database/migrations/2025_11_25_000002_create_cloud_init_scripts_table.php new file mode 100644 index 000000000..11c5b99a3 --- /dev/null +++ b/database/migrations/2025_11_25_000002_create_cloud_init_scripts_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('script'); // Encrypted in the model + $table->timestamps(); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cloud_init_scripts'); + } +}; diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php new file mode 100644 index 000000000..833de7190 --- /dev/null +++ b/resources/views/components/forms/env-var-input.blade.php @@ -0,0 +1,268 @@ +
Upload a backup file or specify a file path on the server
+Download and restore a backup from S3 storage
+