diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php
index bfc65d8d2..20c997656 100644
--- a/app/Actions/Proxy/StartProxy.php
+++ b/app/Actions/Proxy/StartProxy.php
@@ -75,6 +75,10 @@ public function handle(Server $server, bool $async = true, bool $force = false,
' done',
" echo 'Successfully stopped and removed existing coolify-proxy.'",
'fi',
+ ]);
+ // Ensure required networks exist BEFORE docker compose up (networks are declared as external)
+ $commands = $commands->merge(ensureProxyNetworksExist($server));
+ $commands = $commands->merge([
"echo 'Starting coolify-proxy.'",
'docker compose up -d --wait --remove-orphans',
"echo 'Successfully started coolify-proxy.'",
diff --git a/app/Console/Commands/CleanupNames.php b/app/Console/Commands/CleanupNames.php
index 2992e32b9..2451dc3ed 100644
--- a/app/Console/Commands/CleanupNames.php
+++ b/app/Console/Commands/CleanupNames.php
@@ -63,8 +63,6 @@ class CleanupNames extends Command
public function handle(): int
{
- $this->info('๐ Scanning for invalid characters in name fields...');
-
if ($this->option('backup') && ! $this->option('dry-run')) {
$this->createBackup();
}
@@ -75,7 +73,7 @@ public function handle(): int
: $this->modelsToClean;
if ($modelFilter && ! isset($this->modelsToClean[$modelFilter])) {
- $this->error("โ Unknown model: {$modelFilter}");
+ $this->error("Unknown model: {$modelFilter}");
$this->info('Available models: '.implode(', ', array_keys($this->modelsToClean)));
return self::FAILURE;
@@ -88,19 +86,21 @@ public function handle(): int
$this->processModel($modelName, $modelClass);
}
- $this->displaySummary();
-
if (! $this->option('dry-run') && $this->totalCleaned > 0) {
$this->logChanges();
}
+ if ($this->option('dry-run')) {
+ $this->info("Name cleanup: would sanitize {$this->totalCleaned} records");
+ } else {
+ $this->info("Name cleanup: sanitized {$this->totalCleaned} records");
+ }
+
return self::SUCCESS;
}
protected function processModel(string $modelName, string $modelClass): void
{
- $this->info("\n๐ Processing {$modelName}...");
-
try {
$records = $modelClass::all(['id', 'name']);
$cleaned = 0;
@@ -128,21 +128,17 @@ protected function processModel(string $modelName, string $modelClass): void
$cleaned++;
$this->totalCleaned++;
- $this->warn(" ๐งน {$modelName} #{$record->id}:");
- $this->line(' From: '.$this->truncate($originalName, 80));
- $this->line(' To: '.$this->truncate($sanitizedName, 80));
+ // Only log in dry-run mode to preview changes
+ if ($this->option('dry-run')) {
+ $this->warn(" ๐งน {$modelName} #{$record->id}:");
+ $this->line(' From: '.$this->truncate($originalName, 80));
+ $this->line(' To: '.$this->truncate($sanitizedName, 80));
+ }
}
}
- if ($cleaned > 0) {
- $action = $this->option('dry-run') ? 'would be sanitized' : 'sanitized';
- $this->info(" โ
{$cleaned}/{$records->count()} records {$action}");
- } else {
- $this->info(' โจ No invalid characters found');
- }
-
} catch (\Exception $e) {
- $this->error(" โ Error processing {$modelName}: ".$e->getMessage());
+ $this->error("Error processing {$modelName}: ".$e->getMessage());
}
}
@@ -165,28 +161,6 @@ protected function sanitizeName(string $name): string
return $sanitized;
}
- protected function displaySummary(): void
- {
- $this->info("\n".str_repeat('=', 60));
- $this->info('๐ CLEANUP SUMMARY');
- $this->info(str_repeat('=', 60));
-
- $this->line("Records processed: {$this->totalProcessed}");
- $this->line("Records with invalid characters: {$this->totalCleaned}");
-
- if ($this->option('dry-run')) {
- $this->warn("\n๐ DRY RUN - No changes were made to the database");
- $this->info('Run without --dry-run to apply these changes');
- } else {
- if ($this->totalCleaned > 0) {
- $this->info("\nโ
Database successfully sanitized!");
- $this->info('Changes logged to storage/logs/name-cleanup.log');
- } else {
- $this->info("\nโจ No cleanup needed - all names are valid!");
- }
- }
- }
-
protected function logChanges(): void
{
$logFile = storage_path('logs/name-cleanup.log');
@@ -208,8 +182,6 @@ protected function logChanges(): void
protected function createBackup(): void
{
- $this->info('๐พ Creating database backup...');
-
try {
$backupFile = storage_path('backups/name-cleanup-backup-'.now()->format('Y-m-d-H-i-s').'.sql');
@@ -229,15 +201,9 @@ protected function createBackup(): void
);
exec($command, $output, $returnCode);
-
- if ($returnCode === 0) {
- $this->info("โ
Backup created: {$backupFile}");
- } else {
- $this->warn('โ ๏ธ Backup creation may have failed. Proceeding anyway...');
- }
} catch (\Exception $e) {
- $this->warn('โ ๏ธ Could not create backup: '.$e->getMessage());
- $this->warn('Proceeding without backup...');
+ // Log failure but continue - backup is optional safeguard
+ Log::warning('Name cleanup backup failed', ['error' => $e->getMessage()]);
}
}
diff --git a/app/Console/Commands/CleanupRedis.php b/app/Console/Commands/CleanupRedis.php
index abf8010c0..199e168fc 100644
--- a/app/Console/Commands/CleanupRedis.php
+++ b/app/Console/Commands/CleanupRedis.php
@@ -18,10 +18,6 @@ public function handle()
$dryRun = $this->option('dry-run');
$skipOverlapping = $this->option('skip-overlapping');
- if ($dryRun) {
- $this->info('DRY RUN MODE - No data will be deleted');
- }
-
$deletedCount = 0;
$totalKeys = 0;
@@ -29,8 +25,6 @@ public function handle()
$keys = $redis->keys('*');
$totalKeys = count($keys);
- $this->info("Scanning {$totalKeys} keys for cleanup...");
-
foreach ($keys as $key) {
$keyWithoutPrefix = str_replace($prefix, '', $key);
$type = $redis->command('type', [$keyWithoutPrefix]);
@@ -51,14 +45,12 @@ public function handle()
// Clean up overlapping queues if not skipped
if (! $skipOverlapping) {
- $this->info('Cleaning up overlapping queues...');
$overlappingCleaned = $this->cleanupOverlappingQueues($redis, $prefix, $dryRun);
$deletedCount += $overlappingCleaned;
}
// Clean up stale cache locks (WithoutOverlapping middleware)
if ($this->option('clear-locks')) {
- $this->info('Cleaning up stale cache locks...');
$locksCleaned = $this->cleanupCacheLocks($dryRun);
$deletedCount += $locksCleaned;
}
@@ -66,15 +58,14 @@ public function handle()
// Clean up stuck jobs (restart mode = aggressive, runtime mode = conservative)
$isRestart = $this->option('restart');
if ($isRestart || $this->option('clear-locks')) {
- $this->info($isRestart ? 'Cleaning up stuck jobs (RESTART MODE - aggressive)...' : 'Checking for stuck jobs (runtime mode - conservative)...');
$jobsCleaned = $this->cleanupStuckJobs($redis, $prefix, $dryRun, $isRestart);
$deletedCount += $jobsCleaned;
}
if ($dryRun) {
- $this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
+ $this->info("Redis cleanup: would delete {$deletedCount} items");
} else {
- $this->info("Deleted {$deletedCount} out of {$totalKeys} keys");
+ $this->info("Redis cleanup: deleted {$deletedCount} items");
}
}
@@ -85,11 +76,8 @@ private function shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun)
// Delete completed and failed jobs
if (in_array($status, ['completed', 'failed'])) {
- if ($dryRun) {
- $this->line("Would delete job: {$keyWithoutPrefix} (status: {$status})");
- } else {
+ if (! $dryRun) {
$redis->command('del', [$keyWithoutPrefix]);
- $this->line("Deleted job: {$keyWithoutPrefix} (status: {$status})");
}
return true;
@@ -115,11 +103,8 @@ private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryR
foreach ($patterns as $pattern => $description) {
if (str_contains($keyWithoutPrefix, $pattern)) {
- if ($dryRun) {
- $this->line("Would delete {$description}: {$keyWithoutPrefix}");
- } else {
+ if (! $dryRun) {
$redis->command('del', [$keyWithoutPrefix]);
- $this->line("Deleted {$description}: {$keyWithoutPrefix}");
}
return true;
@@ -132,11 +117,8 @@ private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryR
$weekAgo = now()->subDays(7)->timestamp;
if ($timestamp < $weekAgo) {
- if ($dryRun) {
- $this->line("Would delete old timestamped data: {$keyWithoutPrefix}");
- } else {
+ if (! $dryRun) {
$redis->command('del', [$keyWithoutPrefix]);
- $this->line("Deleted old timestamped data: {$keyWithoutPrefix}");
}
return true;
@@ -160,8 +142,6 @@ private function cleanupOverlappingQueues($redis, $prefix, $dryRun)
}
}
- $this->info('Found '.count($queueKeys).' queue-related keys');
-
// Group queues by name pattern to find duplicates
$queueGroups = [];
foreach ($queueKeys as $queueKey) {
@@ -193,7 +173,6 @@ private function cleanupOverlappingQueues($redis, $prefix, $dryRun)
private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun)
{
$cleanedCount = 0;
- $this->line("Processing queue group: {$baseName} (".count($keys).' keys)');
// Sort keys to keep the most recent one
usort($keys, function ($a, $b) {
@@ -244,11 +223,8 @@ private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun)
}
if ($shouldDelete) {
- if ($dryRun) {
- $this->line(" Would delete empty queue: {$redundantKey}");
- } else {
+ if (! $dryRun) {
$redis->command('del', [$redundantKey]);
- $this->line(" Deleted empty queue: {$redundantKey}");
}
$cleanedCount++;
}
@@ -271,15 +247,12 @@ private function deduplicateQueueContents($redis, $queueKey, $dryRun)
if (count($uniqueItems) < count($items)) {
$duplicates = count($items) - count($uniqueItems);
- if ($dryRun) {
- $this->line(" Would remove {$duplicates} duplicate jobs from queue: {$queueKey}");
- } else {
+ if (! $dryRun) {
// Rebuild the list with unique items
$redis->command('del', [$queueKey]);
foreach (array_reverse($uniqueItems) as $item) {
$redis->command('lpush', [$queueKey, $item]);
}
- $this->line(" Removed {$duplicates} duplicate jobs from queue: {$queueKey}");
}
$cleanedCount += $duplicates;
}
@@ -307,13 +280,9 @@ private function cleanupCacheLocks(bool $dryRun): int
}
}
if (empty($lockKeys)) {
- $this->info(' No cache locks found.');
-
return 0;
}
- $this->info(' Found '.count($lockKeys).' cache lock(s)');
-
foreach ($lockKeys as $lockKey) {
// Check TTL to identify stale locks
$ttl = $redis->ttl($lockKey);
@@ -326,18 +295,11 @@ private function cleanupCacheLocks(bool $dryRun): int
$this->warn(" Would delete STALE lock (no expiration): {$lockKey}");
} else {
$redis->del($lockKey);
- $this->info(" โ Deleted STALE lock: {$lockKey}");
}
$cleanedCount++;
- } elseif ($ttl > 0) {
- $this->line(" Skipping active lock (expires in {$ttl}s): {$lockKey}");
}
}
- if ($cleanedCount === 0) {
- $this->info(' No stale locks found (all locks have expiration set)');
- }
-
return $cleanedCount;
}
@@ -453,17 +415,11 @@ private function cleanupStuckJobs($redis, string $prefix, bool $dryRun, bool $is
$redis->command('hset', [$keyWithoutPrefix, 'status', 'failed']);
$redis->command('hset', [$keyWithoutPrefix, 'failed_at', $now]);
$redis->command('hset', [$keyWithoutPrefix, 'exception', "Job cleaned up by cleanup:redis - {$reason}"]);
-
- $this->info(" โ Marked as FAILED: {$jobClass} (processing for ".round($processingTime / 60, 1).' min) - '.$reason);
}
$cleanedCount++;
}
}
- if ($cleanedCount === 0) {
- $this->info($isRestart ? ' No jobs to clean up' : ' No stuck jobs found (all jobs running normally)');
- }
-
return $cleanedCount;
}
}
diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php
index 7a15dd01e..0a98f1dc8 100644
--- a/app/Console/Commands/SyncBunny.php
+++ b/app/Console/Commands/SyncBunny.php
@@ -50,6 +50,7 @@ private function syncReleasesToGitHubRepo(): bool
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
+ $output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
@@ -59,6 +60,7 @@ private function syncReleasesToGitHubRepo(): bool
// Create feature branch
$this->info('Creating feature branch...');
+ $output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
@@ -96,6 +98,7 @@ private function syncReleasesToGitHubRepo(): bool
// Stage and commit
$this->info('Committing changes...');
+ $output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
@@ -133,6 +136,7 @@ private function syncReleasesToGitHubRepo(): bool
// Push to remote
$this->info('Pushing branch to remote...');
+ $output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
@@ -146,6 +150,7 @@ private function syncReleasesToGitHubRepo(): bool
$prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
$prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
+ $output = [];
exec($prCommand, $output, $returnCode);
// Clean up
@@ -171,6 +176,193 @@ private function syncReleasesToGitHubRepo(): bool
}
}
+ /**
+ * Sync both releases.json and versions.json to GitHub repository in one PR
+ */
+ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
+ {
+ $this->info('Syncing releases.json and versions.json to GitHub repository...');
+ try {
+ // 1. Fetch releases from GitHub API
+ $this->info('Fetching releases from GitHub API...');
+ $response = Http::timeout(30)
+ ->get('https://api.github.com/repos/coollabsio/coolify/releases', [
+ 'per_page' => 30,
+ ]);
+
+ if (! $response->successful()) {
+ $this->error('Failed to fetch releases from GitHub: '.$response->status());
+
+ return false;
+ }
+
+ $releases = $response->json();
+
+ // 2. Read versions.json
+ if (! file_exists($versionsLocation)) {
+ $this->error("versions.json not found at: $versionsLocation");
+
+ return false;
+ }
+
+ $file = file_get_contents($versionsLocation);
+ $versionsJson = json_decode($file, true);
+ $actualVersion = data_get($versionsJson, 'coolify.v4.version');
+
+ $timestamp = time();
+ $tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp;
+ $branchName = 'update-releases-and-versions-'.$timestamp;
+ $versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
+
+ // 3. Clone the repository
+ $this->info('Cloning coolify-cdn repository...');
+ $output = [];
+ exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to clone repository: '.implode("\n", $output));
+
+ return false;
+ }
+
+ // 4. Create feature branch
+ $this->info('Creating feature branch...');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to create branch: '.implode("\n", $output));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // 5. Write releases.json
+ $this->info('Writing releases.json...');
+ $releasesPath = "$tmpDir/json/releases.json";
+ $releasesDir = dirname($releasesPath);
+
+ if (! is_dir($releasesDir)) {
+ if (! mkdir($releasesDir, 0755, true)) {
+ $this->error("Failed to create directory: $releasesDir");
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+ }
+
+ $releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ if (file_put_contents($releasesPath, $releasesJsonContent) === false) {
+ $this->error("Failed to write releases.json to: $releasesPath");
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // 6. Write versions.json
+ $this->info('Writing versions.json...');
+ $versionsPath = "$tmpDir/$versionsTargetPath";
+ $versionsDir = dirname($versionsPath);
+
+ if (! is_dir($versionsDir)) {
+ if (! mkdir($versionsDir, 0755, true)) {
+ $this->error("Failed to create directory: $versionsDir");
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+ }
+
+ $versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ if (file_put_contents($versionsPath, $versionsJsonContent) === false) {
+ $this->error("Failed to write versions.json to: $versionsPath");
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // 7. Stage both files
+ $this->info('Staging changes...');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to stage changes: '.implode("\n", $output));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // 8. Check for changes
+ $this->info('Checking for changes...');
+ $statusOutput = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to check repository status: '.implode("\n", $statusOutput));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ if (empty(array_filter($statusOutput))) {
+ $this->info('Both files are already up to date. No changes to commit.');
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return true;
+ }
+
+ // 9. Commit changes
+ $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
+ $commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to commit changes: '.implode("\n", $output));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // 10. Push to remote
+ $this->info('Pushing branch to remote...');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to push branch: '.implode("\n", $output));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // 11. Create pull request
+ $this->info('Creating pull request...');
+ $prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
+ $prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion";
+ $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
+ $output = [];
+ exec($prCommand, $output, $returnCode);
+
+ // 12. Clean up
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ if ($returnCode !== 0) {
+ $this->error('Failed to create PR: '.implode("\n", $output));
+
+ return false;
+ }
+
+ $this->info('Pull request created successfully!');
+ if (! empty($output)) {
+ $this->info('PR URL: '.implode("\n", $output));
+ }
+ $this->info("Version synced: $actualVersion");
+ $this->info('Total releases synced: '.count($releases));
+
+ return true;
+ } catch (\Throwable $e) {
+ $this->error('Error syncing to GitHub: '.$e->getMessage());
+
+ return false;
+ }
+ }
+
/**
* Sync versions.json to GitHub repository via PR
*/
@@ -413,31 +605,48 @@ public function handle()
return;
} elseif ($only_version) {
- $this->warn('โ ๏ธ DEPRECATION WARNING: The --release option is deprecated.');
- $this->warn(' Please use --github-versions instead to create a PR to the coolify-cdn repository.');
- $this->warn(' This option will continue to work but may be removed in a future version.');
- $this->newLine();
-
if ($nightly) {
- $this->info('About to sync NIGHLTY versions.json to BunnyCDN.');
+ $this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.');
} else {
- $this->info('About to sync PRODUCTION versions.json to BunnyCDN.');
+ $this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.');
}
$file = file_get_contents($versions_location);
$json = json_decode($file, true);
$actual_version = data_get($json, 'coolify.v4.version');
- $confirmed = confirm("Are you sure you want to sync to {$actual_version}?");
+ $this->info("Version: {$actual_version}");
+ $this->info('This will:');
+ $this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)');
+ $this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json');
+ $this->newLine();
+
+ $confirmed = confirm('Are you sure you want to proceed?');
if (! $confirmed) {
return;
}
- // Sync versions.json to BunnyCDN
+ // 1. Sync versions.json to BunnyCDN (deprecated but still needed)
+ $this->info('Step 1/2: Syncing versions.json to BunnyCDN...');
Http::pool(fn (Pool $pool) => [
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
]);
- $this->info('versions.json uploaded & purged...');
+ $this->info('โ versions.json uploaded & purged to BunnyCDN');
+ $this->newLine();
+
+ // 2. Create GitHub PR with both releases.json and versions.json
+ $this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...');
+ $githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly);
+ if ($githubSuccess) {
+ $this->info('โ GitHub PR created successfully with both files');
+ } else {
+ $this->error('โ Failed to create GitHub PR');
+ }
+ $this->newLine();
+
+ $this->info('=== Summary ===');
+ $this->info('BunnyCDN sync: โ Complete');
+ $this->info('GitHub PR: '.($githubSuccess ? 'โ Created (releases.json + versions.json)' : 'โ Failed'));
return;
} elseif ($only_github_releases) {
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index 45ac6eb7d..6917de6d5 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -489,17 +489,22 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi
$collectionsToExclude = collect();
}
$commands[] = 'mkdir -p '.$this->backup_dir;
+
+ // Validate and escape database name to prevent command injection
+ validateShellSafePath($databaseName, 'database name');
+ $escapedDatabaseName = escapeshellarg($databaseName);
+
if ($collectionsToExclude->count() === 0) {
if (str($this->database->image)->startsWith('mongo:4')) {
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
} else {
- $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location";
}
} else {
if (str($this->database->image)->startsWith('mongo:4')) {
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
} else {
- $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
}
}
}
@@ -525,7 +530,10 @@ private function backup_standalone_postgresql(string $database): void
if ($this->backup->dump_all) {
$backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
} else {
- $backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $database > $this->backup_location";
+ // Validate and escape database name to prevent command injection
+ validateShellSafePath($database, 'database name');
+ $escapedDatabase = escapeshellarg($database);
+ $backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $escapedDatabase > $this->backup_location";
}
$commands[] = $backupCommand;
@@ -547,7 +555,10 @@ private function backup_standalone_mysql(string $database): void
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
} else {
- $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $database > $this->backup_location";
+ // Validate and escape database name to prevent command injection
+ validateShellSafePath($database, 'database name');
+ $escapedDatabase = escapeshellarg($database);
+ $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location";
}
$this->backup_output = instant_remote_process($commands, $this->server);
$this->backup_output = trim($this->backup_output);
@@ -567,7 +578,10 @@ private function backup_standalone_mariadb(string $database): void
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
} else {
- $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $database > $this->backup_location";
+ // Validate and escape database name to prevent command injection
+ validateShellSafePath($database, 'database name');
+ $escapedDatabase = escapeshellarg($database);
+ $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location";
}
$this->backup_output = instant_remote_process($commands, $this->server);
$this->backup_output = trim($this->backup_output);
diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php
index ff5c2e4f5..b5e1929de 100644
--- a/app/Jobs/ValidateAndInstallServerJob.php
+++ b/app/Jobs/ValidateAndInstallServerJob.php
@@ -168,6 +168,9 @@ public function handle(): void
if (! $this->server->isBuildServer()) {
$proxyShouldRun = CheckProxy::run($this->server, true);
if ($proxyShouldRun) {
+ // Ensure networks exist BEFORE dispatching async proxy startup
+ // This prevents race condition where proxy tries to start before networks are created
+ instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false);
StartProxy::dispatch($this->server);
}
}
diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php
index da543a049..18ad93016 100644
--- a/app/Livewire/Project/Database/BackupEdit.php
+++ b/app/Livewire/Project/Database/BackupEdit.php
@@ -107,6 +107,25 @@ public function syncData(bool $toModel = false)
$this->backup->save_s3 = $this->saveS3;
$this->backup->disable_local_backup = $this->disableLocalBackup;
$this->backup->s3_storage_id = $this->s3StorageId;
+
+ // Validate databases_to_backup to prevent command injection
+ if (filled($this->databasesToBackup)) {
+ $databases = str($this->databasesToBackup)->explode(',');
+ foreach ($databases as $index => $db) {
+ $dbName = trim($db);
+ try {
+ validateShellSafePath($dbName, 'database name');
+ } catch (\Exception $e) {
+ // Provide specific error message indicating which database failed validation
+ $position = $index + 1;
+ throw new \Exception(
+ "Database #{$position} ('{$dbName}') validation failed: ".
+ $e->getMessage()
+ );
+ }
+ }
+ }
+
$this->backup->databases_to_backup = $this->databasesToBackup;
$this->backup->dump_all = $this->dumpAll;
$this->backup->timeout = $this->timeout;
diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php
index 3240aadd2..7ef2cdc4f 100644
--- a/app/Livewire/Project/Database/Postgresql/General.php
+++ b/app/Livewire/Project/Database/Postgresql/General.php
@@ -328,12 +328,15 @@ public function save_init_script($script)
$configuration_dir = database_configuration_dir().'/'.$container_name;
if ($oldScript && $oldScript['filename'] !== $script['filename']) {
- $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
- $delete_command = "rm -f $old_file_path";
try {
+ // Validate and escape filename to prevent command injection
+ validateShellSafePath($oldScript['filename'], 'init script filename');
+ $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
+ $escapedOldPath = escapeshellarg($old_file_path);
+ $delete_command = "rm -f {$escapedOldPath}";
instant_remote_process([$delete_command], $this->server);
} catch (Exception $e) {
- $this->dispatch('error', 'Failed to remove old init script from server: '.$e->getMessage());
+ $this->dispatch('error', $e->getMessage());
return;
}
@@ -370,13 +373,17 @@ public function delete_init_script($script)
if ($found) {
$container_name = $this->database->uuid;
$configuration_dir = database_configuration_dir().'/'.$container_name;
- $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
- $command = "rm -f $file_path";
try {
+ // Validate and escape filename to prevent command injection
+ validateShellSafePath($script['filename'], 'init script filename');
+ $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
+ $escapedPath = escapeshellarg($file_path);
+
+ $command = "rm -f {$escapedPath}";
instant_remote_process([$command], $this->server);
} catch (Exception $e) {
- $this->dispatch('error', 'Failed to remove init script from server: '.$e->getMessage());
+ $this->dispatch('error', $e->getMessage());
return;
}
@@ -405,6 +412,16 @@ public function save_new_init_script()
'new_filename' => 'required|string',
'new_content' => 'required|string',
]);
+
+ try {
+ // Validate filename to prevent command injection
+ validateShellSafePath($this->new_filename, 'init script filename');
+ } catch (Exception $e) {
+ $this->dispatch('error', $e->getMessage());
+
+ return;
+ }
+
$found = collect($this->initScripts)->firstWhere('filename', $this->new_filename);
if ($found) {
$this->dispatch('error', 'Filename already exists.');
diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php
index db171db24..644b100b8 100644
--- a/app/Livewire/Project/Service/Storage.php
+++ b/app/Livewire/Project/Service/Storage.php
@@ -179,6 +179,10 @@ public function submitFileStorageDirectory()
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
+ // Validate paths to prevent command injection
+ validateShellSafePath($this->file_storage_directory_source, 'storage source path');
+ validateShellSafePath($this->file_storage_directory_destination, 'storage destination path');
+
\App\Models\LocalFileVolume::create([
'fs_path' => $this->file_storage_directory_source,
'mount_path' => $this->file_storage_directory_destination,
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
index 3b8d244cc..2030f631e 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
@@ -2,11 +2,14 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
+use App\Models\Environment;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
+use App\Models\Project;
use App\Models\SharedEnvironmentVariable;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Computed;
use Livewire\Component;
class Show extends Component
@@ -184,6 +187,7 @@ public function submit()
$this->serialize();
$this->syncData(true);
+ $this->syncData(false);
$this->dispatch('success', 'Environment variable updated.');
$this->dispatch('envsUpdated');
$this->dispatch('configurationChanged');
@@ -192,6 +196,72 @@ public function submit()
}
}
+ #[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 delete()
{
try {
diff --git a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
index f377bbeb9..c67591cf5 100644
--- a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
+++ b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
@@ -25,13 +25,25 @@ public function delete(string $fileName)
$this->authorize('update', $this->server);
$proxy_path = $this->server->proxyPath();
$proxy_type = $this->server->proxyType();
+
+ // Decode filename: pipes are used to encode dots for Livewire property binding
+ // (e.g., 'my|service.yaml' -> 'my.service.yaml')
+ // This must happen BEFORE validation because validateShellSafePath() correctly
+ // rejects pipe characters as dangerous shell metacharacters
$file = str_replace('|', '.', $fileName);
+
+ // Validate filename to prevent command injection
+ validateShellSafePath($file, 'proxy configuration filename');
+
if ($proxy_type === 'CADDY' && $file === 'Caddyfile') {
$this->dispatch('error', 'Cannot delete Caddyfile.');
return;
}
- instant_remote_process(["rm -f {$proxy_path}/dynamic/{$file}"], $this->server);
+
+ $fullPath = "{$proxy_path}/dynamic/{$file}";
+ $escapedPath = escapeshellarg($fullPath);
+ instant_remote_process(["rm -f {$escapedPath}"], $this->server);
if ($proxy_type === 'CADDY') {
$this->server->reloadCaddy();
}
diff --git a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
index eb2db1cbb..baf7b6b50 100644
--- a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
+++ b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
@@ -41,6 +41,10 @@ public function addDynamicConfiguration()
'fileName' => 'required',
'value' => 'required',
]);
+
+ // Validate filename to prevent command injection
+ validateShellSafePath($this->fileName, 'proxy configuration filename');
+
if (data_get($this->parameters, 'server_uuid')) {
$this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first();
}
@@ -65,8 +69,10 @@ public function addDynamicConfiguration()
}
$proxy_path = $this->server->proxyPath();
$file = "{$proxy_path}/dynamic/{$this->fileName}";
+ $escapedFile = escapeshellarg($file);
+
if ($this->newFile) {
- $exists = instant_remote_process(["test -f $file && echo 1 || echo 0"], $this->server);
+ $exists = instant_remote_process(["test -f {$escapedFile} && echo 1 || echo 0"], $this->server);
if ($exists == 1) {
$this->dispatch('error', 'File already exists');
@@ -80,7 +86,7 @@ public function addDynamicConfiguration()
}
$base64_value = base64_encode($this->value);
instant_remote_process([
- "echo '{$base64_value}' | base64 -d | tee {$file} > /dev/null",
+ "echo '{$base64_value}' | base64 -d | tee {$escapedFile} > /dev/null",
], $this->server);
if ($proxy_type === 'CADDY') {
$this->server->reloadCaddy();
diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php
index c2dcd877b..1a5bd381b 100644
--- a/app/Livewire/Server/ValidateAndInstall.php
+++ b/app/Livewire/Server/ValidateAndInstall.php
@@ -206,6 +206,9 @@ public function validateDockerVersion()
if (! $proxyShouldRun) {
return;
}
+ // Ensure networks exist BEFORE dispatching async proxy startup
+ // This prevents race condition where proxy tries to start before networks are created
+ instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false);
StartProxy::dispatch($this->server);
} else {
$requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 821c69bca..6e920f8e6 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -1035,7 +1035,7 @@ public function isLogDrainEnabled()
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);
+ $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 {
diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php
index 80399a16b..843f01e59 100644
--- a/app/Models/EnvironmentVariable.php
+++ b/app/Models/EnvironmentVariable.php
@@ -190,11 +190,11 @@ private function get_real_environment_variables(?string $environment_variable =
return $environment_variable;
}
foreach ($sharedEnvsFound as $sharedEnv) {
- $type = str($sharedEnv)->match('/(.*?)\./');
+ $type = str($sharedEnv)->trim()->match('/(.*?)\./');
if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) {
continue;
}
- $variable = str($sharedEnv)->match('/\.(.*)/');
+ $variable = str($sharedEnv)->trim()->match('/\.(.*)/');
if ($type->value() === 'environment') {
$id = $resource->environment->id;
} elseif ($type->value() === 'project') {
@@ -231,7 +231,7 @@ private function set_environment_variables(?string $environment_variable = null)
$environment_variable = trim($environment_variable);
$type = str($environment_variable)->after('{{')->before('.')->value;
if (str($environment_variable)->startsWith('{{'.$type) && str($environment_variable)->endsWith('}}')) {
- return encrypt((string) str($environment_variable)->replace(' ', ''));
+ return encrypt($environment_variable);
}
return encrypt($environment_variable);
diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php
index 376ea9c5e..96170dbd6 100644
--- a/app/Models/LocalFileVolume.php
+++ b/app/Models/LocalFileVolume.php
@@ -61,9 +61,14 @@ public function loadStorageOnServer()
$path = $path->after('.');
$path = $workdir.$path;
}
- $isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server);
+
+ // Validate and escape path to prevent command injection
+ validateShellSafePath($path, 'storage path');
+ $escapedPath = escapeshellarg($path);
+
+ $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK') {
- $content = instant_remote_process(["cat $path"], $server, false);
+ $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]';
@@ -91,14 +96,19 @@ public function deleteStorageOnServer()
$path = $path->after('.');
$path = $workdir.$path;
}
- $isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server);
- $isDir = instant_remote_process(["test -d $path && echo OK || echo NOK"], $server);
+
+ // Validate and escape path to prevent command injection
+ 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 ($path && $path != '/' && $path != '.' && $path != '..') {
if ($isFile === 'OK') {
- $commands->push("rm -rf $path > /dev/null 2>&1 || true");
+ $commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true");
} elseif ($isDir === 'OK') {
- $commands->push("rm -rf $path > /dev/null 2>&1 || true");
- $commands->push("rmdir $path > /dev/null 2>&1 || true");
+ $commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true");
+ $commands->push("rmdir {$escapedPath} > /dev/null 2>&1 || true");
}
}
if ($commands->count() > 0) {
@@ -135,10 +145,15 @@ public function saveStorageOnServer()
$path = $path->after('.');
$path = $workdir.$path;
}
- $isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server);
- $isDir = instant_remote_process(["test -d $path && echo OK || echo NOK"], $server);
+
+ // Validate and escape path to prevent command injection
+ 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 $path"], $server, false);
+ $content = instant_remote_process(["cat {$escapedPath}"], $server, false);
$this->is_directory = false;
$this->content = $content;
$this->save();
@@ -151,8 +166,8 @@ public function saveStorageOnServer()
throw new \Exception('The following file is a directory on the server, but you are trying to mark it as a file.
Please delete the directory on the server or mark it as directory.');
}
instant_remote_process([
- "rm -fr $path",
- "touch $path",
+ "rm -fr {$escapedPath}",
+ "touch {$escapedPath}",
], $server, false);
FileStorageChanged::dispatch(data_get($server, 'team_id'));
}
@@ -161,19 +176,19 @@ public function saveStorageOnServer()
$chown = data_get($this, 'chown');
if ($content) {
$content = base64_encode($content);
- $commands->push("echo '$content' | base64 -d | tee $path > /dev/null");
+ $commands->push("echo '$content' | base64 -d | tee {$escapedPath} > /dev/null");
} else {
- $commands->push("touch $path");
+ $commands->push("touch {$escapedPath}");
}
- $commands->push("chmod +x $path");
+ $commands->push("chmod +x {$escapedPath}");
if ($chown) {
- $commands->push("chown $chown $path");
+ $commands->push("chown $chown {$escapedPath}");
}
if ($chmod) {
- $commands->push("chmod $chmod $path");
+ $commands->push("chmod $chmod {$escapedPath}");
}
} elseif ($isDir === 'NOK' && $this->is_directory) {
- $commands->push("mkdir -p $path > /dev/null 2>&1 || true");
+ $commands->push("mkdir -p {$escapedPath} > /dev/null 2>&1 || true");
}
return instant_remote_process($commands, $server);
diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php
index 7cf8ee8fa..4a98e4a51 100644
--- a/app/View/Components/Forms/EnvVarInput.php
+++ b/app/View/Components/Forms/EnvVarInput.php
@@ -26,6 +26,7 @@ public function __construct(
public bool $disabled = false,
public bool $readonly = false,
public ?string $helper = null,
+ public bool $allowToPeak = true,
public string $defaultClass = 'input',
public string $autocomplete = 'off',
public ?int $minlength = null,
@@ -72,6 +73,10 @@ public function render(): View|Closure|string
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
+ if ($this->type === 'password') {
+ $this->defaultClass = $this->defaultClass.' pr-[2.8rem]';
+ }
+
$this->scopeUrls = [
'team' => route('shared-variables.team.index'),
'project' => route('shared-variables.project.index'),
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index dfcc3e190..e7d875777 100644
--- a/bootstrap/helpers/parsers.php
+++ b/bootstrap/helpers/parsers.php
@@ -1644,9 +1644,16 @@ function serviceParser(Service $resource): Collection
if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
- $fqdn = "$fqdn$path";
- $url = "$url$path";
- $fqdnValueForEnv = "$fqdnValueForEnv$path";
+ // Only add path if it's not already present (prevents duplication on subsequent parse() calls)
+ if (! str($fqdn)->endsWith($path)) {
+ $fqdn = "$fqdn$path";
+ }
+ if (! str($url)->endsWith($path)) {
+ $url = "$url$path";
+ }
+ if (! str($fqdnValueForEnv)->endsWith($path)) {
+ $fqdnValueForEnv = "$fqdnValueForEnv$path";
+ }
}
}
diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php
index 08fad4958..6672f8b6f 100644
--- a/bootstrap/helpers/proxy.php
+++ b/bootstrap/helpers/proxy.php
@@ -108,6 +108,37 @@ function connectProxyToNetworks(Server $server)
return $commands->flatten();
}
+
+/**
+ * Ensures all required networks exist before docker compose up.
+ * This must be called BEFORE docker compose up since the compose file declares networks as external.
+ *
+ * @param Server $server The server to ensure networks on
+ * @return \Illuminate\Support\Collection Commands to create networks if they don't exist
+ */
+function ensureProxyNetworksExist(Server $server)
+{
+ ['allNetworks' => $networks] = collectDockerNetworksByServer($server);
+
+ if ($server->isSwarm()) {
+ $commands = $networks->map(function ($network) {
+ return [
+ "echo 'Ensuring network $network exists...'",
+ "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network",
+ ];
+ });
+ } else {
+ $commands = $networks->map(function ($network) {
+ return [
+ "echo 'Ensuring network $network exists...'",
+ "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network",
+ ];
+ });
+ }
+
+ return $commands->flatten();
+}
+
function extractCustomProxyCommands(Server $server, string $existing_config): array
{
$custom_commands = [];
diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php
index 7a7fc3680..b8ef84687 100644
--- a/bootstrap/helpers/sudo.php
+++ b/bootstrap/helpers/sudo.php
@@ -23,38 +23,56 @@ function shouldChangeOwnership(string $path): bool
function parseCommandsByLineForSudo(Collection $commands, Server $server): array
{
$commands = $commands->map(function ($line) {
- if (
- ! str(trim($line))->startsWith([
- 'cd',
- 'command',
- 'echo',
- 'true',
- 'if',
- 'fi',
- 'for',
- 'do',
- 'done',
- 'while',
- 'until',
- 'case',
- 'esac',
- 'select',
- 'then',
- 'else',
- 'elif',
- 'break',
- 'continue',
- '#',
- ])
- ) {
- return "sudo $line";
+ $trimmedLine = trim($line);
+
+ // All bash keywords that should not receive sudo prefix
+ // Using word boundary matching to avoid prefix collisions (e.g., 'do' vs 'docker', 'if' vs 'ifconfig', 'fi' vs 'find')
+ $bashKeywords = [
+ 'cd',
+ 'command',
+ 'declare',
+ 'echo',
+ 'export',
+ 'local',
+ 'readonly',
+ 'return',
+ 'true',
+ 'if',
+ 'fi',
+ 'for',
+ 'done',
+ 'while',
+ 'until',
+ 'case',
+ 'esac',
+ 'select',
+ 'then',
+ 'else',
+ 'elif',
+ 'break',
+ 'continue',
+ 'do',
+ ];
+
+ // Special case: comments (no collision risk with '#')
+ if (str_starts_with($trimmedLine, '#')) {
+ return $line;
}
- if (str(trim($line))->startsWith('if')) {
- return str_replace('if', 'if sudo', $line);
+ // Check all keywords with word boundary matching
+ // Match keyword followed by space, semicolon, or end of line
+ foreach ($bashKeywords as $keyword) {
+ if (preg_match('/^'.preg_quote($keyword, '/').'(\s|;|$)/', $trimmedLine)) {
+ // Special handling for 'if' - insert sudo after 'if '
+ if ($keyword === 'if') {
+ return preg_replace('/^(\s*)if\s+/', '$1if sudo ', $line);
+ }
+
+ return $line;
+ }
}
- return $line;
+ return "sudo $line";
});
$commands = $commands->map(function ($line) use ($server) {
diff --git a/config/constants.php b/config/constants.php
index a59345708..b2c43f2b9 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,7 +2,7 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.450',
+ 'version' => '4.0.0-beta.451',
'helper_version' => '1.0.12',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index 562febf01..fadd5580d 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.450"
+ "version": "4.0.0-beta.451"
},
"nightly": {
- "version": "4.0.0-beta.451"
+ "version": "4.0.0-beta.452"
},
"helper": {
"version": "1.0.12"
diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php
index 53a6b21ec..2466a57f9 100644
--- a/resources/views/components/forms/env-var-input.blade.php
+++ b/resources/views/components/forms/env-var-input.blade.php
@@ -10,7 +10,8 @@
@endif
-