diff --git a/app/Facades/SFTP.php b/app/Facades/SFTP.php new file mode 100644 index 000000000..54b6a294b --- /dev/null +++ b/app/Facades/SFTP.php @@ -0,0 +1,25 @@ +login($username, $password); + } catch (Throwable) { + return false; + } + } +} diff --git a/app/Models/BackupFile.php b/app/Models/BackupFile.php index 2d8237b32..6bb8ee989 100644 --- a/app/Models/BackupFile.php +++ b/app/Models/BackupFile.php @@ -11,6 +11,7 @@ use App\StorageProviders\FTP; use App\StorageProviders\Local; use App\StorageProviders\S3; +use App\StorageProviders\SFTP; use Carbon\Carbon; use Database\Factories\BackupFileFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -107,7 +108,7 @@ public function path(): string return match ($storage->provider) { Dropbox::id() => '/'.$backupName.'/'.$this->name.$extension, - S3::id(), FTP::id(), Local::id() => implode('/', [ + S3::id(), FTP::id(), SFTP::id(), Local::id() => implode('/', [ rtrim((string) $storage->credentials['path'], '/'), $backupName, $this->name.$extension, diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 734066a83..75075b00c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,7 @@ use App\Helpers\FTP; use App\Helpers\Notifier; +use App\Helpers\SFTP; use App\Helpers\SSH; use App\Models\PersonalAccessToken; use Illuminate\Http\Resources\Json\ResourceCollection; @@ -26,6 +27,7 @@ public function boot(): void $this->app->bind('ssh', fn (): SSH => new SSH); $this->app->bind('notifier', fn (): Notifier => new Notifier); $this->app->bind('ftp', fn (): FTP => new FTP); + $this->app->bind('sftp', fn (): SFTP => new SFTP); Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); diff --git a/app/Providers/StorageProviderServiceProvider.php b/app/Providers/StorageProviderServiceProvider.php index 734256b09..e64bda880 100644 --- a/app/Providers/StorageProviderServiceProvider.php +++ b/app/Providers/StorageProviderServiceProvider.php @@ -9,6 +9,7 @@ use App\StorageProviders\FTP; use App\StorageProviders\Local; use App\StorageProviders\S3; +use App\StorageProviders\SFTP; use Illuminate\Support\ServiceProvider; class StorageProviderServiceProvider extends ServiceProvider @@ -21,6 +22,7 @@ public function boot(): void $this->aws(); $this->dropbox(); $this->ftp(); + $this->sftp(); } private function local(): void @@ -118,4 +120,32 @@ private function ftp(): void ) ->register(); } + + private function sftp(): void + { + RegisterStorageProvider::make(SFTP::id()) + ->label('SFTP') + ->handler(SFTP::class) + ->form( + DynamicForm::make([ + DynamicField::make('host') + ->text() + ->label('Host'), + DynamicField::make('port') + ->text() + ->label('Port') + ->default(22), + DynamicField::make('path') + ->text() + ->label('Path'), + DynamicField::make('username') + ->text() + ->label('Username'), + DynamicField::make('password') + ->password() + ->label('Password'), + ]) + ) + ->register(); + } } diff --git a/app/SSH/Storage/SFTP.php b/app/SSH/Storage/SFTP.php new file mode 100644 index 000000000..a515ca180 --- /dev/null +++ b/app/SSH/Storage/SFTP.php @@ -0,0 +1,65 @@ +server->ssh()->exec( + view('ssh.storage.sftp.upload', [ + 'src' => $src, + 'dest' => $dest, + 'host' => $this->storageProvider->credentials['host'], + 'port' => $this->storageProvider->credentials['port'], + 'username' => $this->storageProvider->credentials['username'], + 'password' => $this->storageProvider->credentials['password'], + ]), + 'upload-to-sftp' + ); + + return [ + 'size' => null, + ]; + } + + /** + * @throws SSHError + */ + public function download(string $src, string $dest): void + { + $this->server->ssh()->exec( + view('ssh.storage.sftp.download', [ + 'src' => $src, + 'dest' => $dest, + 'host' => $this->storageProvider->credentials['host'], + 'port' => $this->storageProvider->credentials['port'], + 'username' => $this->storageProvider->credentials['username'], + 'password' => $this->storageProvider->credentials['password'], + ]), + 'download-from-sftp' + ); + } + + /** + * @throws SSHError + */ + public function delete(string $src): void + { + $this->server->ssh()->exec( + view('ssh.storage.sftp.delete-file', [ + 'src' => $src, + 'host' => $this->storageProvider->credentials['host'], + 'port' => $this->storageProvider->credentials['port'], + 'username' => $this->storageProvider->credentials['username'], + 'password' => $this->storageProvider->credentials['password'], + ]), + 'delete-from-sftp' + ); + } +} diff --git a/app/StorageProviders/SFTP.php b/app/StorageProviders/SFTP.php new file mode 100644 index 000000000..7add003fc --- /dev/null +++ b/app/StorageProviders/SFTP.php @@ -0,0 +1,62 @@ + + */ + public function validationRules(): array + { + return [ + 'host' => 'required', + 'port' => [ + 'required', + 'integer', + 'min:1', + 'max:65535', + ], + 'path' => 'required', + 'username' => 'required', + 'password' => 'required', + ]; + } + + public function credentialData(array $input): array + { + return [ + 'host' => $input['host'], + 'port' => $input['port'], + 'path' => $input['path'], + 'username' => $input['username'], + 'password' => $input['password'], + ]; + } + + public function connect(): bool + { + $credentials = $this->storageProvider->credentials; + + return SFTPFacade::connect( + $credentials['host'], + (int) $credentials['port'], + $credentials['username'], + $credentials['password'] + ); + } + + public function ssh(Server $server): Storage + { + return new \App\SSH\Storage\SFTP($server, $this->storageProvider); + } +} diff --git a/app/Support/Testing/SFTPFake.php b/app/Support/Testing/SFTPFake.php new file mode 100644 index 000000000..5bea8af8f --- /dev/null +++ b/app/Support/Testing/SFTPFake.php @@ -0,0 +1,37 @@ + + */ + protected array $connections = []; + + public function connect(string $host, int $port, string $username, string $password): bool + { + $this->connections[] = ['host' => $host, 'port' => $port, 'username' => $username]; + + return true; + } + + public function assertConnected(string $host): void + { + if ($this->connections === []) { + Assert::fail('No SFTP connections are made'); + } + $connected = false; + foreach ($this->connections as $connection) { + if ($connection['host'] === $host) { + $connected = true; + break; + } + } + if (! $connected) { + Assert::fail('The expected host is not connected'); + } + } +} diff --git a/resources/views/ssh/storage/sftp/delete-file.blade.php b/resources/views/ssh/storage/sftp/delete-file.blade.php new file mode 100644 index 000000000..c033fb296 --- /dev/null +++ b/resources/views/ssh/storage/sftp/delete-file.blade.php @@ -0,0 +1 @@ +curl -k -u "{{ $username }}:{{ $password }}" sftp://{{ $host }}:{{ $port }}/ -Q "rm {{ $src }}" diff --git a/resources/views/ssh/storage/sftp/download.blade.php b/resources/views/ssh/storage/sftp/download.blade.php new file mode 100644 index 000000000..3ac8eaa82 --- /dev/null +++ b/resources/views/ssh/storage/sftp/download.blade.php @@ -0,0 +1 @@ +curl -k -u "{{ $username }}:{{ $password }}" sftp://{{ $host }}:{{ $port }}/{{ $src }} -o "{{ $dest }}" diff --git a/resources/views/ssh/storage/sftp/upload.blade.php b/resources/views/ssh/storage/sftp/upload.blade.php new file mode 100644 index 000000000..d6dbd20ca --- /dev/null +++ b/resources/views/ssh/storage/sftp/upload.blade.php @@ -0,0 +1 @@ +curl -k --ftp-create-dirs -T "{{ $src }}" -u "{{ $username }}:{{ $password }}" sftp://{{ $host }}:{{ $port }}/{{ $dest }} diff --git a/tests/Feature/API/StorageProvidersTest.php b/tests/Feature/API/StorageProvidersTest.php index 43009323e..544557ac6 100644 --- a/tests/Feature/API/StorageProvidersTest.php +++ b/tests/Feature/API/StorageProvidersTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\API; use App\Facades\FTP; +use App\Facades\SFTP; use App\Models\Backup; use App\Models\Database; use App\Models\StorageProvider as StorageProviderModel; @@ -35,6 +36,10 @@ public function test_create(array $input): void FTP::fake(); } + if ($input['provider'] === \App\StorageProviders\SFTP::id()) { + SFTP::fake(); + } + $this->json('POST', route('api.projects.storage-providers.create', [ 'project' => $this->user->current_project_id, ]), $input) @@ -309,6 +314,29 @@ public static function createData(): array 'global' => 1, ], ], + [ + [ + 'provider' => \App\StorageProviders\SFTP::id(), + 'name' => 'sftp-test', + 'host' => '1.2.3.4', + 'port' => '22', + 'path' => '/home/vito', + 'username' => 'username', + 'password' => 'password', + ], + ], + [ + [ + 'provider' => \App\StorageProviders\SFTP::id(), + 'name' => 'sftp-test', + 'host' => '1.2.3.4', + 'port' => '22', + 'path' => '/home/vito', + 'username' => 'username', + 'password' => 'password', + 'global' => 1, + ], + ], ]; } } diff --git a/tests/Feature/StorageProvidersTest.php b/tests/Feature/StorageProvidersTest.php index 2dbc02b94..0f5f51eee 100644 --- a/tests/Feature/StorageProvidersTest.php +++ b/tests/Feature/StorageProvidersTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Facades\FTP; +use App\Facades\SFTP; use App\Models\Backup; use App\Models\Database; use App\Models\StorageProvider as StorageProviderModel; @@ -35,12 +36,20 @@ public function test_create(array $input): void FTP::fake(); } + if ($input['provider'] === \App\StorageProviders\SFTP::id()) { + SFTP::fake(); + } + $this->post(route('storage-providers.store'), $input); if ($input['provider'] === \App\StorageProviders\FTP::id()) { FTP::assertConnected($input['host']); } + if ($input['provider'] === \App\StorageProviders\SFTP::id()) { + SFTP::assertConnected($input['host']); + } + $this->assertDatabaseHas('storage_providers', [ 'provider' => $input['provider'], 'profile' => $input['name'], @@ -288,6 +297,29 @@ public static function createData(): array 'global' => 1, ], ], + [ + [ + 'provider' => \App\StorageProviders\SFTP::id(), + 'name' => 'sftp-test', + 'host' => '1.2.3.4', + 'port' => '22', + 'path' => '/home/vito', + 'username' => 'username', + 'password' => 'password', + ], + ], + [ + [ + 'provider' => \App\StorageProviders\SFTP::id(), + 'name' => 'sftp-test', + 'host' => '1.2.3.4', + 'port' => '22', + 'path' => '/home/vito', + 'username' => 'username', + 'password' => 'password', + 'global' => 1, + ], + ], ]; } }