diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php index 23fafd969a..7fe8c8abef 100644 --- a/app/Livewire/Project/Database/ImportForm.php +++ b/app/Livewire/Project/Database/ImportForm.php @@ -18,6 +18,7 @@ use App\Support\DatabaseImport\DatabaseImportException; use App\Support\DatabaseImport\DatabaseImportSource; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Storage; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; @@ -179,6 +180,13 @@ class ImportForm extends Component public string $customLocation = ''; + /** + * The file source the user selected: 'upload' after a completed upload, 'server' after a checked server path. + * Only the server sets it, so an import never falls back to an older upload. + */ + #[Locked] + public ?string $fileSource = null; + public ?int $activityId = null; // S3 Restore properties @@ -429,14 +437,14 @@ class ImportForm extends Component } try { - $escapedPath = escapeshellarg($this->customLocation); - $result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false); - if (blank($result)) { + if (! $this->serverFileExists($this->customLocation)) { $this->dispatch('error', 'The file does not exist or has been deleted.'); return; } $this->filename = $this->customLocation; + $this->fileSource = 'server'; + $this->discardStagedUpload(); $this->selectSqliteDatabaseFor($this->customLocation); $this->dispatch('success', 'The file exists.'); } catch (\Throwable $e) { @@ -445,6 +453,62 @@ class ImportForm extends Component } } + public function updatedCustomLocation(): void + { + if ($this->fileSource === 'server') { + $this->fileSource = null; + } + } + + /** + * Called by the upload form after an upload is complete. + */ + public function selectUploadedFile(string $name): void + { + $this->authorize('update', $this->resource); + + if (! Storage::exists($this->stagedUploadPath())) { + $this->fileSource = null; + $this->dispatch('error', 'The uploaded file was not found. Please upload it again.'); + + return; + } + + $this->fileSource = 'upload'; + $this->customLocation = ''; + $this->selectSqliteDatabaseFor($name); + } + + private function stagedUploadPath(): string + { + return "upload/{$this->resource->uuid}/restore"; + } + + /** + * Delete an upload that was not imported when the user selects another source. Skipped while + * an import of this database runs, because that import can still copy the upload. + */ + private function discardStagedUpload(): void + { + $lock = Cache::lock(StartDatabaseImport::lockKey($this->resource->uuid), 60); + if (! $lock->get()) { + return; + } + + try { + Storage::delete($this->stagedUploadPath()); + } finally { + $lock->release(); + } + } + + protected function serverFileExists(string $path): bool + { + $escapedPath = escapeshellarg($path); + + return filled(instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false)); + } + public function runImport(string $password = ''): bool|string { if (! verifyPasswordConfirmation($password, $this)) { @@ -459,7 +523,7 @@ class ImportForm extends Component return true; } - if ($this->filename === '') { + if (! in_array($this->fileSource, ['upload', 'server'], true)) { $this->dispatch('error', 'Please select a file to import.'); return true; @@ -473,7 +537,10 @@ class ImportForm extends Component try { $this->importRunning = true; - $source = Storage::exists("upload/{$this->resourceUuid}/restore") + if ($this->fileSource === 'server') { + $this->discardStagedUpload(); + } + $source = $this->fileSource === 'upload' ? new DatabaseImportSource('upload', dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting, keepOwners: $this->keepOwners, sqliteDatabase: $this->sqliteDatabase, restoreMysqlUsers: $this->restoreMysqlUsers) : new DatabaseImportSource('server', path: $this->customLocation, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting, keepOwners: $this->keepOwners, sqliteDatabase: $this->sqliteDatabase, restoreMysqlUsers: $this->restoreMysqlUsers); $activity = StartDatabaseImport::run($this->resource, $source, (int) $this->resourceTeamId()); @@ -497,6 +564,7 @@ class ImportForm extends Component return true; } finally { $this->filename = null; + $this->fileSource = null; $this->importCommands = []; } @@ -637,6 +705,7 @@ class ImportForm extends Component try { $this->importRunning = true; + $this->discardStagedUpload(); $source = new DatabaseImportSource('s3', path: $this->s3Path, s3StorageUuid: (string) $this->s3StorageId, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting, keepOwners: $this->keepOwners, sqliteDatabase: $this->sqliteDatabase, restoreMysqlUsers: $this->restoreMysqlUsers); $activity = StartDatabaseImport::run($this->resource, $source, (int) $this->resourceTeamId()); $this->activityId = $activity->id; diff --git a/resources/views/livewire/project/database/import-form.blade.php b/resources/views/livewire/project/database/import-form.blade.php index 0462b81f0c..bc1f86c451 100644 --- a/resources/views/livewire/project/database/import-form.blade.php +++ b/resources/views/livewire/project/database/import-form.blade.php @@ -36,7 +36,9 @@ }); this.on('complete', function (file) { $wire.filename = file.name; - $wire.selectSqliteDatabaseFor(file.name); + if (file.status === Dropzone.SUCCESS) { + $wire.selectUploadedFile(file.name); + } $wire.filesize = Number(file.size / 1024 / 1024).toFixed(2) + ' MB'; $wire.isUploading = false; }); diff --git a/tests/Feature/DatabaseImportFormResourceTeamTest.php b/tests/Feature/DatabaseImportFormResourceTeamTest.php index 29db848a8b..b8dce3e53b 100644 --- a/tests/Feature/DatabaseImportFormResourceTeamTest.php +++ b/tests/Feature/DatabaseImportFormResourceTeamTest.php @@ -73,6 +73,11 @@ class ImportFormResourceTeamTestComponent extends ImportForm $this->loadAvailableS3Storages(); } + protected function serverFileExists(string $path): bool + { + return true; + } + public function render() { return view('livewire.project.database.import-form'); @@ -106,7 +111,8 @@ test('a file import started after a session team switch runs for the database te ->andReturn(fakeStartedDatabaseImport()); importFormAfterTeamSwitch() - ->set('filename', 'backup.dump') + ->set('customLocation', '/backups/backup.dump') + ->call('checkFile') ->call('runImport') ->assertNotDispatched('error') ->assertDispatched('databaserestore'); diff --git a/tests/Feature/DatabaseImportFormRunningStateTest.php b/tests/Feature/DatabaseImportFormRunningStateTest.php index a355be7782..70ae33de3a 100644 --- a/tests/Feature/DatabaseImportFormRunningStateTest.php +++ b/tests/Feature/DatabaseImportFormRunningStateTest.php @@ -66,6 +66,11 @@ class ImportFormRunningStateTestComponent extends ImportForm $this->loadAvailableS3Storages(); } + protected function serverFileExists(string $path): bool + { + return true; + } + public function render() { return view('livewire.project.database.import-form'); @@ -78,7 +83,8 @@ function importForm(): Testable 'database' => test()->database, 'server' => test()->server, ]) - ->set('filename', 'backup.dump') + ->set('customLocation', '/backups/backup.dump') + ->call('checkFile') ->set('s3StorageId', 1) ->set('s3Path', '/backups/backup.dump') ->set('s3FileSize', 1024); diff --git a/tests/Feature/DatabaseImportFormSourceSelectionTest.php b/tests/Feature/DatabaseImportFormSourceSelectionTest.php new file mode 100644 index 0000000000..1028488a4c --- /dev/null +++ b/tests/Feature/DatabaseImportFormSourceSelectionTest.php @@ -0,0 +1,165 @@ + 0, + 'disable_two_step_confirmation' => true, + ]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'admin']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'docker'] + ); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'db', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'db', + 'image' => 'postgres:17', + 'status' => 'running', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->stagedUpload = "upload/{$this->database->uuid}/restore"; +}); + +class ImportFormSourceSelectionTestComponent extends ImportForm +{ + public function mount($database = null, $server = null): void + { + $this->resourceId = $database->id; + $this->resourceType = $database::class; + $this->serverId = $server->id; + $this->container = $database->uuid; + $this->resourceUuid = $database->uuid; + $this->resourceStatus = $database->status ?? ''; + $this->resourceDbType = $database->type(); + } + + protected function serverFileExists(string $path): bool + { + return true; + } + + public function render() + { + return view('livewire.project.database.import-form'); + } +} + +function importFormSourceSelection(): Testable +{ + return Livewire::test(ImportFormSourceSelectionTestComponent::class, [ + 'database' => test()->database, + 'server' => test()->server, + ]); +} + +function expectImportFromSource(string $type, ?string $path = null): void +{ + StartDatabaseImport::shouldRun()->once() + ->withArgs(fn ($resource, DatabaseImportSource $source): bool => $source->type === $type && $source->path === $path) + ->andReturn(Activity::create(['log_name' => 'default', 'description' => 'queued', 'properties' => ['status' => 'queued']])); +} + +test('a server path import ignores and deletes an earlier upload that was not imported', function () { + Storage::put($this->stagedUpload, 'old upload'); + expectImportFromSource('server', '/backups/server.dump'); + + importFormSourceSelection() + ->set('replaceExisting', true) + ->set('customLocation', '/backups/server.dump') + ->call('checkFile') + ->call('runImport') + ->assertNotDispatched('error') + ->assertDispatched('databaserestore'); + + expect(Storage::exists($this->stagedUpload))->toBeFalse(); +}); + +test('an upload import uses the uploaded file', function () { + expectImportFromSource('upload'); + + $form = importFormSourceSelection() + ->set('customLocation', '/backups/server.dump') + ->call('checkFile'); + + Storage::put($this->stagedUpload, 'new upload'); + + $form->call('selectUploadedFile', 'backup.dump') + ->assertSet('customLocation', '') + ->call('runImport') + ->assertNotDispatched('error') + ->assertDispatched('databaserestore'); +}); + +test('an upload is not selected when the staged file is missing', function () { + StartDatabaseImport::shouldRun()->never(); + + importFormSourceSelection() + ->call('selectUploadedFile', 'backup.dump') + ->assertDispatched('error') + ->call('runImport') + ->assertDispatched('error', 'Please select a file to import.'); +}); + +test('a server path changed after the check is not imported', function () { + Storage::put($this->stagedUpload, 'old upload'); + StartDatabaseImport::shouldRun()->never(); + + importFormSourceSelection() + ->set('customLocation', '/backups/server.dump') + ->call('checkFile') + ->set('customLocation', '/backups/other.dump') + ->call('runImport') + ->assertDispatched('error', 'Please select a file to import.'); +}); + +test('an S3 restore deletes an earlier upload that was not imported', function () { + Storage::put($this->stagedUpload, 'old upload'); + StartDatabaseImport::shouldRun()->once() + ->withArgs(fn ($resource, DatabaseImportSource $source): bool => $source->type === 's3') + ->andReturn(Activity::create(['log_name' => 'default', 'description' => 'queued', 'properties' => ['status' => 'queued']])); + + importFormSourceSelection() + ->set('s3StorageId', 1) + ->set('s3Path', '/backups/backup.dump') + ->set('s3FileSize', 1024) + ->call('restoreFromS3') + ->assertDispatched('databaserestore'); + + expect(Storage::exists($this->stagedUpload))->toBeFalse(); +}); diff --git a/tests/Feature/DatabaseImportRestoreOptionsTest.php b/tests/Feature/DatabaseImportRestoreOptionsTest.php index fd3e48db47..bb254d96e0 100644 --- a/tests/Feature/DatabaseImportRestoreOptionsTest.php +++ b/tests/Feature/DatabaseImportRestoreOptionsTest.php @@ -97,6 +97,11 @@ class ImportFormRestoreOptionsTestComponent extends ImportForm $this->initializeRestoreOptions(); } + protected function serverFileExists(string $path): bool + { + return true; + } + public function render() { return view('livewire.project.database.import-form'); @@ -151,7 +156,7 @@ test('the import form strips owners by default and keeps them when the option is $form->set('keepOwners', true); expect($form->get('restoreCommandText'))->not->toContain('--no-owner'); - $form->set('filename', 'backup.dump')->set('customLocation', '/backups/backup.dump')->call('runImport'); + $form->set('customLocation', '/backups/backup.dump')->call('checkFile')->call('runImport'); }); test('passes the restore users option to the MariaDB all-databases restore script', function (bool $restoreMysqlUsers) { @@ -174,7 +179,7 @@ test('the import form skips MySQL users by default and restores them when the op $form->set('restoreMysqlUsers', true)->assertSee('passwords'); expect($form->get('restoreCommandText'))->not->toContain('awk'); - $form->set('filename', 'all.sql.gz')->set('customLocation', '/backups/all.sql.gz')->call('runImport'); + $form->set('customLocation', '/backups/all.sql.gz')->call('checkFile')->call('runImport'); }); test('the import form does not offer the restore users option for PostgreSQL', function () { @@ -194,7 +199,7 @@ test('the import form lets the user pick the SQLite database file to restore int ->assertSet('sqliteDatabase', 'cache.db'); expect($form->get('restoreCommandText'))->toContain("'/var/lib/sqlite/cache.db'"); - $form->set('filename', 'backup.gz')->set('customLocation', '/backups/backup.gz')->call('runImport'); + $form->set('customLocation', '/backups/sqlite-backup-cache.db-1700000000.gz')->call('checkFile')->assertSet('sqliteDatabase', 'cache.db')->call('runImport'); }); test('the import form does not accept a SQLite target from the client that is not a database file', function () {