diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index a585baa69..ec995cafb 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -660,7 +660,16 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $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}/"; + + // Build S3 path with optional prefix + $s3Path = $bucket; + if (filled($this->s3->path)) { + $pathPrefix = ltrim($this->s3->path, '/'); + $s3Path .= '/'.$pathPrefix; + } + $s3Path .= $this->backup_dir.'/'; + + $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/{$s3Path}"; instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $this->s3_uploaded = true; diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index d101d7b58..330aabaa4 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -29,6 +29,8 @@ class Form extends Component public string $secret; + public ?string $path = null; + public ?bool $isUsable = null; protected function rules(): array @@ -42,6 +44,7 @@ class Form extends Component 'secret' => 'required|max:255', 'bucket' => 'required|max:255', 'endpoint' => 'required|url|max:255', + 'path' => 'nullable|max:255|regex:/^[a-zA-Z0-9\/\-_\.]*$/', ]; } @@ -63,6 +66,8 @@ class Form extends Component 'endpoint.required' => 'The Endpoint field is required.', 'endpoint.url' => 'The Endpoint must be a valid URL.', 'endpoint.max' => 'The Endpoint may not be greater than 255 characters.', + 'path.max' => 'The Path Prefix may not be greater than 255 characters.', + 'path.regex' => 'The Path Prefix may only contain letters, numbers, slashes (/), dashes (-), underscores (_), and dots (.).', ] ); } @@ -76,6 +81,7 @@ class Form extends Component 'secret' => 'Secret', 'bucket' => 'Bucket', 'endpoint' => 'Endpoint', + 'path' => 'Path Prefix', ]; /** @@ -94,6 +100,7 @@ class Form extends Component $this->storage->region = $this->region; $this->storage->key = $this->key; $this->storage->secret = $this->secret; + $this->storage->path = $this->path; $this->storage->is_usable = $this->isUsable; } else { // Sync FROM model (on load/refresh) @@ -104,6 +111,7 @@ class Form extends Component $this->region = $this->storage->region; $this->key = $this->storage->key; $this->secret = $this->storage->secret; + $this->path = $this->storage->path; $this->isUsable = $this->storage->is_usable; } } diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 5df36db33..77b5c5f5b 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -209,6 +209,18 @@ function deleteBackupsS3(string|array|null $filenames, S3Storage $s3): void $filenames = [$filenames]; } + // Apply path prefix if configured + if (filled($s3->path)) { + $pathPrefix = ltrim($s3->path, '/'); + $filenames = array_map(function ($filename) use ($pathPrefix) { + // The filename is the local path (e.g., /var/lib/coolify/backups/databases/...) + // We need to prepend the path prefix to match the S3 key structure + $cleanFilename = ltrim($filename, '/'); + + return $pathPrefix.'/'.$cleanFilename; + }, $filenames); + } + $disk = Storage::build([ 'driver' => 's3', 'key' => $s3->key, diff --git a/database/migrations/2025_12_26_000001_add_path_to_s3_storages_table.php b/database/migrations/2025_12_26_000001_add_path_to_s3_storages_table.php new file mode 100644 index 000000000..92aedb977 --- /dev/null +++ b/database/migrations/2025_12_26_000001_add_path_to_s3_storages_table.php @@ -0,0 +1,28 @@ +string('path')->nullable()->after('endpoint'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('s3_storages', function (Blueprint $table) { + $table->dropColumn('path'); + }); + } +}; diff --git a/resources/views/livewire/storage/form.blade.php b/resources/views/livewire/storage/form.blade.php index 850d7735f..6278adf93 100644 --- a/resources/views/livewire/storage/form.blade.php +++ b/resources/views/livewire/storage/form.blade.php @@ -40,6 +40,11 @@ +
+ +
diff --git a/tests/Unit/S3StorageTest.php b/tests/Unit/S3StorageTest.php index 6709f381d..cd80080a3 100644 --- a/tests/Unit/S3StorageTest.php +++ b/tests/Unit/S3StorageTest.php @@ -51,3 +51,43 @@ test('S3Storage model is guarded correctly', function () { // The model should have $guarded = [] which means everything is fillable expect($s3Storage->getGuarded())->toBe([]); }); + +test('S3Storage path attribute normalizes path correctly', function () { + $s3Storage = new S3Storage; + + // Path should be normalized to start with / + $s3Storage->path = 'backups/coolify'; + expect($s3Storage->path)->toBe('/backups/coolify'); + + // Path with leading slash should remain unchanged + $s3Storage->path = '/backups/coolify'; + expect($s3Storage->path)->toBe('/backups/coolify'); + + // Empty path should return null + $s3Storage->path = ''; + expect($s3Storage->path)->toBeNull(); + + // Null path should return null + $s3Storage->path = null; + expect($s3Storage->path)->toBeNull(); + + // Path with whitespace should be trimmed + $s3Storage->path = ' backups/coolify '; + expect($s3Storage->path)->toBe('/backups/coolify'); +}); + +test('S3Storage path attribute handles various path formats', function () { + $s3Storage = new S3Storage; + + // Simple path + $s3Storage->path = 'instance-1'; + expect($s3Storage->path)->toBe('/instance-1'); + + // Nested path + $s3Storage->path = 'production/backups/db'; + expect($s3Storage->path)->toBe('/production/backups/db'); + + // Path with special characters + $s3Storage->path = 'my-instance_2024.backups'; + expect($s3Storage->path)->toBe('/my-instance_2024.backups'); +});