From 72a0a57f0e9fc7e152ab3051a7aec71d7d200660 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:15:55 +0200 Subject: [PATCH] fix(backups): enforce authorization and safe retention - Gate volume backup retention and S3 controls by update permission - Preserve backup records when S3 deletion fails - Share SFTP download streaming with consistent missing-file handling - Handle schedule creation errors and link service database backups --- .../Api/VolumeBackupsController.php | 42 +++++- .../Project/Application/Backup/Create.php | 34 ++--- app/Livewire/Project/Service/FileStorage.php | 19 ++- .../Project/Shared/Storages/VolumeBackups.php | 8 +- bootstrap/helpers/databases.php | 47 ++++++- .../shared/storages/volume-backups.blade.php | 16 +-- .../volume-backups/retention.blade.php | 13 +- .../storages/volume-backups/s3.blade.php | 16 ++- routes/web.php | 75 ++--------- .../BackupRetentionAndStaleDetectionTest.php | 51 ++++++++ ...bleLivewireComponentsAuthorizationTest.php | 32 +++++ tests/Feature/VolumeBackupTest.php | 120 ++++++++++++++++++ tests/Unit/BackupDownloadHelperTest.php | 72 +++++++++++ 13 files changed, 433 insertions(+), 112 deletions(-) create mode 100644 tests/Unit/BackupDownloadHelperTest.php diff --git a/app/Http/Controllers/Api/VolumeBackupsController.php b/app/Http/Controllers/Api/VolumeBackupsController.php index fdd8caf13..e30cdad38 100644 --- a/app/Http/Controllers/Api/VolumeBackupsController.php +++ b/app/Http/Controllers/Api/VolumeBackupsController.php @@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\MessageBag; use OpenApi\Attributes as OA; use RuntimeException; @@ -161,6 +162,26 @@ public function upsert(Request $request): JsonResponse return response()->json(['message' => 'Storage not found.'], 404); } + ['errors' => $errors, 's3Storage' => $s3Storage, 'saveToS3' => $saveToS3] = $this->validateUpsertRequest($request, $storage, $teamId); + + if ($errors->isNotEmpty()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + return $this->persistSchedule($request, $storage, $teamId, $s3Storage, $saveToS3, $resourceType, $resource); + } + + /** + * @return array{errors: MessageBag, s3Storage: S3Storage|null, saveToS3: bool} + */ + private function validateUpsertRequest( + Request $request, + LocalPersistentVolume|LocalFileVolume $storage, + int|string $teamId, + ): array { $validator = customApiValidator($request->all(), [ 'frequency' => 'required|string|max:255', 'enabled' => 'boolean', @@ -223,13 +244,22 @@ public function upsert(Request $request): JsonResponse $errors->add('storage_uuid', 'Only directory file storages can be backed up.'); } - if ($errors->isNotEmpty()) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => $errors, - ], 422); - } + return [ + 'errors' => $errors, + 's3Storage' => $s3Storage, + 'saveToS3' => $saveToS3, + ]; + } + private function persistSchedule( + Request $request, + LocalPersistentVolume|LocalFileVolume $storage, + int|string $teamId, + ?S3Storage $s3Storage, + bool $saveToS3, + string $resourceType, + Model $resource, + ): JsonResponse { $backup = $storage->scheduledBackups()->updateOrCreate([], [ 'team_id' => $teamId, 'frequency' => $request->string('frequency')->toString(), diff --git a/app/Livewire/Project/Application/Backup/Create.php b/app/Livewire/Project/Application/Backup/Create.php index 6c22bd7f3..4e46ba779 100644 --- a/app/Livewire/Project/Application/Backup/Create.php +++ b/app/Livewire/Project/Application/Backup/Create.php @@ -85,22 +85,26 @@ public function submit(): void return; } - $backup = $target->scheduledBackups()->updateOrCreate( - [], - [ - 'team_id' => currentTeam()->id, - 'frequency' => $this->frequency, - 'enabled' => true, - ], - ); + try { + $backup = $target->scheduledBackups()->updateOrCreate( + [], + [ + 'team_id' => currentTeam()->id, + 'frequency' => $this->frequency, + 'enabled' => true, + ], + ); - $this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled storage backup created.' : 'Scheduled storage backup updated.'); - $this->redirectRoute('project.application.backup.show', [ - 'project_uuid' => $this->application->project()->uuid, - 'environment_uuid' => $this->application->environment->uuid, - 'application_uuid' => $this->application->uuid, - 'backup_uuid' => $backup->uuid, - ], navigate: true); + $this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled storage backup created.' : 'Scheduled storage backup updated.'); + redirectRoute($this, 'project.application.backup.show', [ + 'project_uuid' => $this->application->project()->uuid, + 'environment_uuid' => $this->application->environment->uuid, + 'application_uuid' => $this->application->uuid, + 'backup_uuid' => $backup->uuid, + ]); + } catch (\Throwable $e) { + handleError($e, $this); + } } public function render() diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 31501e4cf..84a0daec8 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -84,7 +84,24 @@ public function refreshBackupStatus(): void $this->hasEnabledBackup = $backup?->enabled ?? false; $this->backupUrl = null; - if (! $this->hasEnabledBackup || ! $this->resource instanceof Application) { + if (! $this->hasEnabledBackup) { + return; + } + + if ($this->resource instanceof ServiceDatabase) { + $this->backupUrl = route('project.service.database.backups', [ + 'project_uuid' => $this->resource->service->project()->uuid, + 'environment_uuid' => $this->resource->service->environment->uuid, + 'service_uuid' => $this->resource->service->uuid, + 'stack_service_uuid' => $this->resource->uuid, + ]); + + return; + } + + if (! $this->resource instanceof Application) { + $this->hasEnabledBackup = false; + return; } diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index 56301cf4e..3e56c4086 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -9,6 +9,8 @@ use App\Models\S3Storage; use App\Models\ScheduledVolumeBackup; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\RedirectResponse; +use Illuminate\Routing\Redirector; use Illuminate\Support\Collection; use Livewire\Component; use Livewire\WithPagination; @@ -178,13 +180,13 @@ public function toggleEnabled(): void $this->dispatch('success', $this->enabled ? 'Storage backups enabled.' : 'Storage backups disabled.'); } - public function backupNow() + public function backupNow(): Redirector|RedirectResponse|null { $this->authorize('update', $this->resource); if (! $this->backup) { if (! $this->validateSettings()) { - return; + return null; } $this->enabled = false; @@ -202,7 +204,7 @@ public function backupNow() ]); } - public function delete(?string $password = null, array $selectedActions = []) + public function delete(?string $password = null, array $selectedActions = []): bool|string { $this->authorize('update', $this->resource); diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 4aeda9dbf..8f4f373b7 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -17,6 +17,10 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use RuntimeException; +use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Throwable; function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql { @@ -194,6 +198,41 @@ function deleteBackupsLocally(string|array|null $filenames, Server $server, bool $foldersToCheck->each(fn ($folder) => deleteEmptyBackupFolder($folder, $server)); } +function streamBackupFromServer(Server $server, string $filename, string $contentType): StreamedResponse +{ + $disk = Storage::build([ + 'driver' => 'sftp', + 'host' => $server->ip, + 'port' => (int) $server->port, + 'username' => $server->user, + 'privateKey' => $server->privateKey->getKeyLocation(), + 'root' => '/', + ]); + + if (! $disk->exists($filename)) { + throw new FileNotFoundException($filename); + } + + return new StreamedResponse(function () use ($disk, $filename) { + if (ob_get_level()) { + ob_end_clean(); + } + $stream = $disk->readStream($filename); + if ($stream === false || is_null($stream)) { + abort(500, 'Failed to open stream for the requested file.'); + } + while (! feof($stream)) { + echo fread($stream, 2048); + flush(); + } + + fclose($stream); + }, 200, [ + 'Content-Type' => $contentType, + 'Content-Disposition' => 'attachment; filename="'.basename($filename).'"', + ]); +} + function deleteBackupsS3(string|array|null $filenames, S3Storage $s3): void { if (empty($filenames) || ! $s3) { @@ -430,8 +469,12 @@ function deleteOldBackupsFromS3($backup): Collection ->all(); if (! empty($filesToDelete)) { - deleteBackupsS3($filesToDelete, $backup->s3); - $processedBackups = $backupsToDelete; + try { + deleteBackupsS3($filesToDelete, $backup->s3); + $processedBackups = $backupsToDelete; + } catch (Throwable $e) { + report($e); + } } return $processedBackups; diff --git a/resources/views/livewire/project/shared/storages/volume-backups.blade.php b/resources/views/livewire/project/shared/storages/volume-backups.blade.php index e86c3a585..cc354e88d 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups.blade.php @@ -10,12 +10,12 @@ @else @include('livewire.project.shared.storages.volume-backups.general') @endif - -@script - -@endscript + @script + + @endscript + diff --git a/resources/views/livewire/project/shared/storages/volume-backups/retention.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/retention.blade.php index 575eca1f5..6ee971b1d 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups/retention.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups/retention.blade.php @@ -1,7 +1,8 @@