diff --git a/backend/.env.development.example b/backend/.env.development.example index b9bcae5..b442235 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -39,4 +39,6 @@ TEST_SUPABASE_HOST= TEST_SUPABASE_PORT= TEST_SUPABASE_USERNAME= TEST_SUPABASE_PASSWORD= -TEST_SUPABASE_DATABASE= \ No newline at end of file +TEST_SUPABASE_DATABASE= +# FTP +TEST_FTP_PORT=7007 \ No newline at end of file diff --git a/backend/docker-compose.yml.example b/backend/docker-compose.yml.example index 97f90e1..a6cfed6 100644 --- a/backend/docker-compose.yml.example +++ b/backend/docker-compose.yml.example @@ -132,3 +132,17 @@ services: -s "backups;/shared;yes;no;no;testuser" -p container_name: test-nas + + # Test FTP server + test-ftp: + image: stilliard/pure-ftpd:latest + ports: + - "${TEST_FTP_PORT:-21}:21" + - "30000-30009:30000-30009" + environment: + - PUBLICHOST=localhost + - FTP_USER_NAME=testuser + - FTP_USER_PASS=testpassword + - FTP_USER_HOME=/home/ftpusers/testuser + - FTP_PASSIVE_PORTS=30000:30009 + container_name: test-ftp diff --git a/backend/go.mod b/backend/go.mod index 9366ac4..e895762 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,6 +12,7 @@ require ( github.com/google/uuid v1.6.0 github.com/ilyakaznacheev/cleanenv v1.5.0 github.com/jackc/pgx/v5 v5.7.5 + github.com/jlaffaye/ftp v0.2.0 github.com/jmoiron/sqlx v1.4.0 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 @@ -27,7 +28,11 @@ require ( gorm.io/gorm v1.26.1 ) -require github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect +require ( + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect +) require ( cloud.google.com/go/auth v0.16.2 // indirect diff --git a/backend/go.sum b/backend/go.sum index 45f30c8..d13ad90 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -107,6 +107,10 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI= github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE= github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= @@ -123,6 +127,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg= +github.com/jlaffaye/ftp v0.2.0/go.mod h1:is2Ds5qkhceAPy2xD6RLI6hmp/qysSoymZ+Z2uTnspI= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a3c8f4e..1cad04d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -48,6 +48,7 @@ type EnvVariables struct { TestAzuriteBlobPort string `env:"TEST_AZURITE_BLOB_PORT"` TestNASPort string `env:"TEST_NAS_PORT"` + TestFTPPort string `env:"TEST_FTP_PORT"` // oauth GitHubClientID string `env:"GITHUB_CLIENT_ID"` diff --git a/backend/internal/features/storages/controller_test.go b/backend/internal/features/storages/controller_test.go index f0ce942..aa27b6c 100644 --- a/backend/internal/features/storages/controller_test.go +++ b/backend/internal/features/storages/controller_test.go @@ -8,6 +8,7 @@ import ( audit_logs "postgresus-backend/internal/features/audit_logs" azure_blob_storage "postgresus-backend/internal/features/storages/models/azure_blob" + ftp_storage "postgresus-backend/internal/features/storages/models/ftp" google_drive_storage "postgresus-backend/internal/features/storages/models/google_drive" local_storage "postgresus-backend/internal/features/storages/models/local" nas_storage "postgresus-backend/internal/features/storages/models/nas" @@ -738,6 +739,55 @@ func Test_StorageSensitiveDataLifecycle_AllTypes(t *testing.T) { assert.Equal(t, "", storage.GoogleDriveStorage.TokenJSON) }, }, + { + name: "FTP Storage", + storageType: StorageTypeFTP, + createStorage: func(workspaceID uuid.UUID) *Storage { + return &Storage{ + WorkspaceID: workspaceID, + Type: StorageTypeFTP, + Name: "Test FTP Storage", + FTPStorage: &ftp_storage.FTPStorage{ + Host: "ftp.example.com", + Port: 21, + Username: "testuser", + Password: "original-password", + UseSSL: false, + PassiveMode: true, + Path: "/backups", + }, + } + }, + updateStorage: func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage { + return &Storage{ + ID: storageID, + WorkspaceID: workspaceID, + Type: StorageTypeFTP, + Name: "Updated FTP Storage", + FTPStorage: &ftp_storage.FTPStorage{ + Host: "ftp2.example.com", + Port: 2121, + Username: "testuser2", + Password: "", + UseSSL: true, + PassiveMode: false, + Path: "/backups2", + }, + } + }, + verifySensitiveData: func(t *testing.T, storage *Storage) { + assert.True(t, strings.HasPrefix(storage.FTPStorage.Password, "enc:"), + "Password should be encrypted with 'enc:' prefix") + + encryptor := encryption.GetFieldEncryptor() + password, err := encryptor.Decrypt(storage.ID, storage.FTPStorage.Password) + assert.NoError(t, err) + assert.Equal(t, "original-password", password) + }, + verifyHiddenData: func(t *testing.T, storage *Storage) { + assert.Equal(t, "", storage.FTPStorage.Password) + }, + }, } for _, tc := range testCases { diff --git a/backend/internal/features/storages/enums.go b/backend/internal/features/storages/enums.go index aa96a95..b673614 100644 --- a/backend/internal/features/storages/enums.go +++ b/backend/internal/features/storages/enums.go @@ -8,4 +8,5 @@ const ( StorageTypeGoogleDrive StorageType = "GOOGLE_DRIVE" StorageTypeNAS StorageType = "NAS" StorageTypeAzureBlob StorageType = "AZURE_BLOB" + StorageTypeFTP StorageType = "FTP" ) diff --git a/backend/internal/features/storages/model.go b/backend/internal/features/storages/model.go index edeae16..a9bfe6a 100644 --- a/backend/internal/features/storages/model.go +++ b/backend/internal/features/storages/model.go @@ -6,6 +6,7 @@ import ( "io" "log/slog" azure_blob_storage "postgresus-backend/internal/features/storages/models/azure_blob" + ftp_storage "postgresus-backend/internal/features/storages/models/ftp" google_drive_storage "postgresus-backend/internal/features/storages/models/google_drive" local_storage "postgresus-backend/internal/features/storages/models/local" nas_storage "postgresus-backend/internal/features/storages/models/nas" @@ -28,6 +29,7 @@ type Storage struct { GoogleDriveStorage *google_drive_storage.GoogleDriveStorage `json:"googleDriveStorage" gorm:"foreignKey:StorageID"` NASStorage *nas_storage.NASStorage `json:"nasStorage" gorm:"foreignKey:StorageID"` AzureBlobStorage *azure_blob_storage.AzureBlobStorage `json:"azureBlobStorage" gorm:"foreignKey:StorageID"` + FTPStorage *ftp_storage.FTPStorage `json:"ftpStorage" gorm:"foreignKey:StorageID"` } func (s *Storage) SaveFile( @@ -109,6 +111,10 @@ func (s *Storage) Update(incoming *Storage) { if s.AzureBlobStorage != nil && incoming.AzureBlobStorage != nil { s.AzureBlobStorage.Update(incoming.AzureBlobStorage) } + case StorageTypeFTP: + if s.FTPStorage != nil && incoming.FTPStorage != nil { + s.FTPStorage.Update(incoming.FTPStorage) + } } } @@ -124,6 +130,8 @@ func (s *Storage) getSpecificStorage() StorageFileSaver { return s.NASStorage case StorageTypeAzureBlob: return s.AzureBlobStorage + case StorageTypeFTP: + return s.FTPStorage default: panic("invalid storage type: " + string(s.Type)) } diff --git a/backend/internal/features/storages/model_test.go b/backend/internal/features/storages/model_test.go index e75241b..9e4be76 100644 --- a/backend/internal/features/storages/model_test.go +++ b/backend/internal/features/storages/model_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "postgresus-backend/internal/config" azure_blob_storage "postgresus-backend/internal/features/storages/models/azure_blob" + ftp_storage "postgresus-backend/internal/features/storages/models/ftp" google_drive_storage "postgresus-backend/internal/features/storages/models/google_drive" local_storage "postgresus-backend/internal/features/storages/models/local" nas_storage "postgresus-backend/internal/features/storages/models/nas" @@ -70,6 +71,14 @@ func Test_Storage_BasicOperations(t *testing.T) { } } + // Setup FTP port + ftpPort := 21 + if portStr := config.GetEnv().TestFTPPort; portStr != "" { + if port, err := strconv.Atoi(portStr); err == nil { + ftpPort = port + } + } + // Run tests testCases := []struct { name string @@ -124,6 +133,19 @@ func Test_Storage_BasicOperations(t *testing.T) { ContainerName: azuriteContainer.containerNameStr, }, }, + { + name: "FTPStorage", + storage: &ftp_storage.FTPStorage{ + StorageID: uuid.New(), + Host: "localhost", + Port: ftpPort, + Username: "testuser", + Password: "testpassword", + UseSSL: false, + PassiveMode: true, + Path: "test-files", + }, + }, } // Add Google Drive storage test only if environment variables are available diff --git a/backend/internal/features/storages/models/ftp/model.go b/backend/internal/features/storages/models/ftp/model.go new file mode 100644 index 0000000..66d38de --- /dev/null +++ b/backend/internal/features/storages/models/ftp/model.go @@ -0,0 +1,352 @@ +package ftp_storage + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "log/slog" + "postgresus-backend/internal/util/encryption" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jlaffaye/ftp" +) + +const ( + ftpConnectTimeout = 30 * time.Second + ftpChunkSize = 16 * 1024 * 1024 +) + +type FTPStorage struct { + StorageID uuid.UUID `json:"storageId" gorm:"primaryKey;type:uuid;column:storage_id"` + Host string `json:"host" gorm:"not null;type:text;column:host"` + Port int `json:"port" gorm:"not null;default:21;column:port"` + Username string `json:"username" gorm:"not null;type:text;column:username"` + Password string `json:"password" gorm:"not null;type:text;column:password"` + Path string `json:"path" gorm:"type:text;column:path"` + UseSSL bool `json:"useSsl" gorm:"not null;default:false;column:use_ssl"` + SkipTLSVerify bool `json:"skipTlsVerify" gorm:"not null;default:false;column:skip_tls_verify"` + PassiveMode bool `json:"passiveMode" gorm:"not null;default:true;column:passive_mode"` +} + +func (f *FTPStorage) TableName() string { + return "ftp_storages" +} + +func (f *FTPStorage) SaveFile( + ctx context.Context, + encryptor encryption.FieldEncryptor, + logger *slog.Logger, + fileID uuid.UUID, + file io.Reader, +) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + logger.Info("Starting to save file to FTP storage", "fileId", fileID.String(), "host", f.Host) + + conn, err := f.connect(encryptor) + if err != nil { + logger.Error("Failed to connect to FTP", "fileId", fileID.String(), "error", err) + return fmt.Errorf("failed to connect to FTP: %w", err) + } + defer func() { + if quitErr := conn.Quit(); quitErr != nil { + logger.Error( + "Failed to close FTP connection", + "fileId", + fileID.String(), + "error", + quitErr, + ) + } + }() + + if f.Path != "" { + if err := f.ensureDirectory(conn, f.Path); err != nil { + logger.Error( + "Failed to ensure directory", + "fileId", + fileID.String(), + "path", + f.Path, + "error", + err, + ) + return fmt.Errorf("failed to ensure directory: %w", err) + } + } + + filePath := f.getFilePath(fileID.String()) + logger.Debug("Uploading file to FTP", "fileId", fileID.String(), "filePath", filePath) + + ctxReader := &contextReader{ctx: ctx, reader: file} + + err = conn.Stor(filePath, ctxReader) + if err != nil { + select { + case <-ctx.Done(): + logger.Info("FTP upload cancelled", "fileId", fileID.String()) + return ctx.Err() + default: + logger.Error("Failed to upload file to FTP", "fileId", fileID.String(), "error", err) + return fmt.Errorf("failed to upload file to FTP: %w", err) + } + } + + logger.Info( + "Successfully saved file to FTP storage", + "fileId", + fileID.String(), + "filePath", + filePath, + ) + return nil +} + +func (f *FTPStorage) GetFile( + encryptor encryption.FieldEncryptor, + fileID uuid.UUID, +) (io.ReadCloser, error) { + conn, err := f.connect(encryptor) + if err != nil { + return nil, fmt.Errorf("failed to connect to FTP: %w", err) + } + + filePath := f.getFilePath(fileID.String()) + + resp, err := conn.Retr(filePath) + if err != nil { + _ = conn.Quit() + return nil, fmt.Errorf("failed to retrieve file from FTP: %w", err) + } + + return &ftpFileReader{ + response: resp, + conn: conn, + }, nil +} + +func (f *FTPStorage) DeleteFile(encryptor encryption.FieldEncryptor, fileID uuid.UUID) error { + conn, err := f.connect(encryptor) + if err != nil { + return fmt.Errorf("failed to connect to FTP: %w", err) + } + defer func() { + _ = conn.Quit() + }() + + filePath := f.getFilePath(fileID.String()) + + _, err = conn.FileSize(filePath) + if err != nil { + return nil + } + + err = conn.Delete(filePath) + if err != nil { + return fmt.Errorf("failed to delete file from FTP: %w", err) + } + + return nil +} + +func (f *FTPStorage) Validate(encryptor encryption.FieldEncryptor) error { + if f.Host == "" { + return errors.New("FTP host is required") + } + if f.Username == "" { + return errors.New("FTP username is required") + } + if f.Password == "" { + return errors.New("FTP password is required") + } + if f.Port <= 0 || f.Port > 65535 { + return errors.New("FTP port must be between 1 and 65535") + } + + return nil +} + +func (f *FTPStorage) TestConnection(encryptor encryption.FieldEncryptor) error { + conn, err := f.connect(encryptor) + if err != nil { + return fmt.Errorf("failed to connect to FTP: %w", err) + } + defer func() { + _ = conn.Quit() + }() + + if f.Path != "" { + if err := f.ensureDirectory(conn, f.Path); err != nil { + return fmt.Errorf("failed to access or create path '%s': %w", f.Path, err) + } + } + + return nil +} + +func (f *FTPStorage) HideSensitiveData() { + f.Password = "" +} + +func (f *FTPStorage) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error { + if f.Password != "" { + encrypted, err := encryptor.Encrypt(f.StorageID, f.Password) + if err != nil { + return fmt.Errorf("failed to encrypt FTP password: %w", err) + } + f.Password = encrypted + } + + return nil +} + +func (f *FTPStorage) Update(incoming *FTPStorage) { + f.Host = incoming.Host + f.Port = incoming.Port + f.Username = incoming.Username + f.UseSSL = incoming.UseSSL + f.SkipTLSVerify = incoming.SkipTLSVerify + f.PassiveMode = incoming.PassiveMode + f.Path = incoming.Path + + if incoming.Password != "" { + f.Password = incoming.Password + } +} + +func (f *FTPStorage) connect(encryptor encryption.FieldEncryptor) (*ftp.ServerConn, error) { + password, err := encryptor.Decrypt(f.StorageID, f.Password) + if err != nil { + return nil, fmt.Errorf("failed to decrypt FTP password: %w", err) + } + + address := fmt.Sprintf("%s:%d", f.Host, f.Port) + + var conn *ftp.ServerConn + if f.UseSSL { + tlsConfig := &tls.Config{ + ServerName: f.Host, + InsecureSkipVerify: f.SkipTLSVerify, + } + conn, err = ftp.Dial(address, + ftp.DialWithTimeout(ftpConnectTimeout), + ftp.DialWithExplicitTLS(tlsConfig), + ) + } else { + conn, err = ftp.Dial(address, ftp.DialWithTimeout(ftpConnectTimeout)) + } + if err != nil { + return nil, fmt.Errorf("failed to dial FTP server: %w", err) + } + + err = conn.Login(f.Username, password) + if err != nil { + _ = conn.Quit() + return nil, fmt.Errorf("failed to login to FTP server: %w", err) + } + + return conn, nil +} + +func (f *FTPStorage) ensureDirectory(conn *ftp.ServerConn, path string) error { + path = strings.TrimPrefix(path, "/") + path = strings.TrimSuffix(path, "/") + + if path == "" { + return nil + } + + parts := strings.Split(path, "/") + currentPath := "" + + for _, part := range parts { + if part == "" || part == "." { + continue + } + + if currentPath == "" { + currentPath = part + } else { + currentPath = currentPath + "/" + part + } + + err := conn.ChangeDir(currentPath) + if err != nil { + err = conn.MakeDir(currentPath) + if err != nil { + return fmt.Errorf("failed to create directory '%s': %w", currentPath, err) + } + } + + err = conn.ChangeDirToParent() + if err != nil { + return fmt.Errorf("failed to change to parent directory: %w", err) + } + } + + return nil +} + +func (f *FTPStorage) getFilePath(filename string) string { + if f.Path == "" { + return filename + } + + path := strings.TrimPrefix(f.Path, "/") + path = strings.TrimSuffix(path, "/") + + return path + "/" + filename +} + +type ftpFileReader struct { + response *ftp.Response + conn *ftp.ServerConn +} + +func (r *ftpFileReader) Read(p []byte) (n int, err error) { + return r.response.Read(p) +} + +func (r *ftpFileReader) Close() error { + var errs []error + + if r.response != nil { + if err := r.response.Close(); err != nil { + errs = append(errs, fmt.Errorf("failed to close response: %w", err)) + } + } + + if r.conn != nil { + if err := r.conn.Quit(); err != nil { + errs = append(errs, fmt.Errorf("failed to close connection: %w", err)) + } + } + + if len(errs) > 0 { + return errs[0] + } + + return nil +} + +type contextReader struct { + ctx context.Context + reader io.Reader +} + +func (r *contextReader) Read(p []byte) (n int, err error) { + select { + case <-r.ctx.Done(): + return 0, r.ctx.Err() + default: + return r.reader.Read(p) + } +} diff --git a/backend/internal/features/storages/repository.go b/backend/internal/features/storages/repository.go index 9b0269f..e55d53a 100644 --- a/backend/internal/features/storages/repository.go +++ b/backend/internal/features/storages/repository.go @@ -34,17 +34,21 @@ func (r *StorageRepository) Save(storage *Storage) (*Storage, error) { if storage.AzureBlobStorage != nil { storage.AzureBlobStorage.StorageID = storage.ID } + case StorageTypeFTP: + if storage.FTPStorage != nil { + storage.FTPStorage.StorageID = storage.ID + } } if storage.ID == uuid.Nil { if err := tx.Create(storage). - Omit("LocalStorage", "S3Storage", "GoogleDriveStorage", "NASStorage", "AzureBlobStorage"). + Omit("LocalStorage", "S3Storage", "GoogleDriveStorage", "NASStorage", "AzureBlobStorage", "FTPStorage"). Error; err != nil { return err } } else { if err := tx.Save(storage). - Omit("LocalStorage", "S3Storage", "GoogleDriveStorage", "NASStorage", "AzureBlobStorage"). + Omit("LocalStorage", "S3Storage", "GoogleDriveStorage", "NASStorage", "AzureBlobStorage", "FTPStorage"). Error; err != nil { return err } @@ -86,6 +90,13 @@ func (r *StorageRepository) Save(storage *Storage) (*Storage, error) { return err } } + case StorageTypeFTP: + if storage.FTPStorage != nil { + storage.FTPStorage.StorageID = storage.ID // Ensure ID is set + if err := tx.Save(storage.FTPStorage).Error; err != nil { + return err + } + } } return nil @@ -108,6 +119,7 @@ func (r *StorageRepository) FindByID(id uuid.UUID) (*Storage, error) { Preload("GoogleDriveStorage"). Preload("NASStorage"). Preload("AzureBlobStorage"). + Preload("FTPStorage"). Where("id = ?", id). First(&s).Error; err != nil { return nil, err @@ -126,6 +138,7 @@ func (r *StorageRepository) FindByWorkspaceID(workspaceID uuid.UUID) ([]*Storage Preload("GoogleDriveStorage"). Preload("NASStorage"). Preload("AzureBlobStorage"). + Preload("FTPStorage"). Where("workspace_id = ?", workspaceID). Order("name ASC"). Find(&storages).Error; err != nil { @@ -169,6 +182,12 @@ func (r *StorageRepository) Delete(s *Storage) error { return err } } + case StorageTypeFTP: + if s.FTPStorage != nil { + if err := tx.Delete(s.FTPStorage).Error; err != nil { + return err + } + } } // Delete the main storage diff --git a/backend/migrations/20251213180403_add_ftp_storages.sql b/backend/migrations/20251213180403_add_ftp_storages.sql new file mode 100644 index 0000000..0a7270c --- /dev/null +++ b/backend/migrations/20251213180403_add_ftp_storages.sql @@ -0,0 +1,29 @@ +-- +goose Up +-- +goose StatementBegin + +CREATE TABLE ftp_storages ( + storage_id UUID PRIMARY KEY, + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 21, + username TEXT NOT NULL, + password TEXT NOT NULL, + path TEXT, + use_ssl BOOLEAN NOT NULL DEFAULT FALSE, + skip_tls_verify BOOLEAN NOT NULL DEFAULT FALSE, + passive_mode BOOLEAN NOT NULL DEFAULT TRUE +); + +ALTER TABLE ftp_storages + ADD CONSTRAINT fk_ftp_storages_storage + FOREIGN KEY (storage_id) + REFERENCES storages (id) + ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP TABLE IF EXISTS ftp_storages; + +-- +goose StatementEnd diff --git a/frontend/public/icons/storages/ftp.svg b/frontend/public/icons/storages/ftp.svg new file mode 100644 index 0000000..e56d6d4 --- /dev/null +++ b/frontend/public/icons/storages/ftp.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/entity/storages/index.ts b/frontend/src/entity/storages/index.ts index 935193c..dccc7bc 100644 --- a/frontend/src/entity/storages/index.ts +++ b/frontend/src/entity/storages/index.ts @@ -8,3 +8,4 @@ export { getStorageLogoFromType } from './models/getStorageLogoFromType'; export { getStorageNameFromType } from './models/getStorageNameFromType'; export { type GoogleDriveStorage } from './models/GoogleDriveStorage'; export { type AzureBlobStorage } from './models/AzureBlobStorage'; +export { type FTPStorage } from './models/FTPStorage'; diff --git a/frontend/src/entity/storages/models/FTPStorage.ts b/frontend/src/entity/storages/models/FTPStorage.ts new file mode 100644 index 0000000..86eca80 --- /dev/null +++ b/frontend/src/entity/storages/models/FTPStorage.ts @@ -0,0 +1,10 @@ +export interface FTPStorage { + host: string; + port: number; + username: string; + password: string; + useSsl: boolean; + skipTlsVerify?: boolean; + passiveMode: boolean; + path?: string; +} diff --git a/frontend/src/entity/storages/models/Storage.ts b/frontend/src/entity/storages/models/Storage.ts index 2181628..16c4549 100644 --- a/frontend/src/entity/storages/models/Storage.ts +++ b/frontend/src/entity/storages/models/Storage.ts @@ -1,4 +1,5 @@ import type { AzureBlobStorage } from './AzureBlobStorage'; +import type { FTPStorage } from './FTPStorage'; import type { GoogleDriveStorage } from './GoogleDriveStorage'; import type { LocalStorage } from './LocalStorage'; import type { NASStorage } from './NASStorage'; @@ -18,4 +19,5 @@ export interface Storage { googleDriveStorage?: GoogleDriveStorage; nasStorage?: NASStorage; azureBlobStorage?: AzureBlobStorage; + ftpStorage?: FTPStorage; } diff --git a/frontend/src/entity/storages/models/StorageType.ts b/frontend/src/entity/storages/models/StorageType.ts index 80f7270..9d5960c 100644 --- a/frontend/src/entity/storages/models/StorageType.ts +++ b/frontend/src/entity/storages/models/StorageType.ts @@ -4,4 +4,5 @@ export enum StorageType { GOOGLE_DRIVE = 'GOOGLE_DRIVE', NAS = 'NAS', AZURE_BLOB = 'AZURE_BLOB', + FTP = 'FTP', } diff --git a/frontend/src/entity/storages/models/getStorageLogoFromType.ts b/frontend/src/entity/storages/models/getStorageLogoFromType.ts index 922ccda..bc138cd 100644 --- a/frontend/src/entity/storages/models/getStorageLogoFromType.ts +++ b/frontend/src/entity/storages/models/getStorageLogoFromType.ts @@ -12,6 +12,8 @@ export const getStorageLogoFromType = (type: StorageType) => { return '/icons/storages/nas.svg'; case StorageType.AZURE_BLOB: return '/icons/storages/azure.svg'; + case StorageType.FTP: + return '/icons/storages/ftp.svg'; default: return ''; } diff --git a/frontend/src/entity/storages/models/getStorageNameFromType.ts b/frontend/src/entity/storages/models/getStorageNameFromType.ts index ae7ac4a..3955134 100644 --- a/frontend/src/entity/storages/models/getStorageNameFromType.ts +++ b/frontend/src/entity/storages/models/getStorageNameFromType.ts @@ -12,6 +12,8 @@ export const getStorageNameFromType = (type: StorageType) => { return 'NAS'; case StorageType.AZURE_BLOB: return 'Azure Blob Storage'; + case StorageType.FTP: + return 'FTP'; default: return ''; } diff --git a/frontend/src/features/storages/ui/edit/EditStorageComponent.tsx b/frontend/src/features/storages/ui/edit/EditStorageComponent.tsx index f78375d..79e5afd 100644 --- a/frontend/src/features/storages/ui/edit/EditStorageComponent.tsx +++ b/frontend/src/features/storages/ui/edit/EditStorageComponent.tsx @@ -9,6 +9,7 @@ import { } from '../../../../entity/storages'; import { ToastHelper } from '../../../../shared/toast'; import { EditAzureBlobStorageComponent } from './storages/EditAzureBlobStorageComponent'; +import { EditFTPStorageComponent } from './storages/EditFTPStorageComponent'; import { EditGoogleDriveStorageComponent } from './storages/EditGoogleDriveStorageComponent'; import { EditNASStorageComponent } from './storages/EditNASStorageComponent'; import { EditS3StorageComponent } from './storages/EditS3StorageComponent'; @@ -86,6 +87,7 @@ export function EditStorageComponent({ storage.s3Storage = undefined; storage.googleDriveStorage = undefined; storage.azureBlobStorage = undefined; + storage.ftpStorage = undefined; if (type === StorageType.LOCAL) { storage.localStorage = {}; @@ -133,6 +135,18 @@ export function EditStorageComponent({ }; } + if (type === StorageType.FTP) { + storage.ftpStorage = { + host: '', + port: 21, + username: '', + password: '', + useSsl: false, + passiveMode: true, + path: '', + }; + } + setStorage( JSON.parse( JSON.stringify({ @@ -235,6 +249,19 @@ export function EditStorageComponent({ } } + if (storage.type === StorageType.FTP) { + if (storage.id) { + return storage.ftpStorage?.host && storage.ftpStorage?.port && storage.ftpStorage?.username; + } + + return ( + storage.ftpStorage?.host && + storage.ftpStorage?.port && + storage.ftpStorage?.username && + storage.ftpStorage?.password + ); + } + return false; }; @@ -271,6 +298,7 @@ export function EditStorageComponent({ { label: 'Google Drive', value: StorageType.GOOGLE_DRIVE }, { label: 'NAS', value: StorageType.NAS }, { label: 'Azure Blob Storage', value: StorageType.AZURE_BLOB }, + { label: 'FTP', value: StorageType.FTP }, ]} onChange={(value) => { setStorageType(value); @@ -332,6 +360,17 @@ export function EditStorageComponent({ }} /> )} + + {storage?.type === StorageType.FTP && ( + { + setIsUnsaved(true); + setIsTestConnectionSuccess(false); + }} + /> + )}
diff --git a/frontend/src/features/storages/ui/edit/storages/EditFTPStorageComponent.tsx b/frontend/src/features/storages/ui/edit/storages/EditFTPStorageComponent.tsx new file mode 100644 index 0000000..99c381b --- /dev/null +++ b/frontend/src/features/storages/ui/edit/storages/EditFTPStorageComponent.tsx @@ -0,0 +1,260 @@ +import { DownOutlined, InfoCircleOutlined, UpOutlined } from '@ant-design/icons'; +import { Checkbox, Input, InputNumber, Tooltip } from 'antd'; +import { useState } from 'react'; + +import type { Storage } from '../../../../../entity/storages'; + +interface Props { + storage: Storage; + setStorage: (storage: Storage) => void; + setUnsaved: () => void; +} + +export function EditFTPStorageComponent({ storage, setStorage, setUnsaved }: Props) { + const hasAdvancedValues = + !!storage?.ftpStorage?.skipTlsVerify || storage?.ftpStorage?.passiveMode === false; + const [showAdvanced, setShowAdvanced] = useState(hasAdvancedValues); + + return ( + <> +
+
Host
+ { + if (!storage?.ftpStorage) return; + + setStorage({ + ...storage, + ftpStorage: { + ...storage.ftpStorage, + host: e.target.value.trim(), + }, + }); + setUnsaved(); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="ftp.example.com" + /> +
+ +
+
Port
+ { + if (!storage?.ftpStorage || !value) return; + + setStorage({ + ...storage, + ftpStorage: { + ...storage.ftpStorage, + port: value, + }, + }); + setUnsaved(); + }} + size="small" + className="w-full max-w-[250px]" + min={1} + max={65535} + placeholder="21" + /> +
+ +
+
Username
+ { + if (!storage?.ftpStorage) return; + + setStorage({ + ...storage, + ftpStorage: { + ...storage.ftpStorage, + username: e.target.value.trim(), + }, + }); + setUnsaved(); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="username" + /> +
+ +
+
Password
+ { + if (!storage?.ftpStorage) return; + + setStorage({ + ...storage, + ftpStorage: { + ...storage.ftpStorage, + password: e.target.value, + }, + }); + setUnsaved(); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="password" + /> +
+ +
+
Path
+
+ { + if (!storage?.ftpStorage) return; + + let pathValue = e.target.value.trim(); + if (pathValue.startsWith('/')) { + pathValue = pathValue.substring(1); + } + + setStorage({ + ...storage, + ftpStorage: { + ...storage.ftpStorage, + path: pathValue || undefined, + }, + }); + setUnsaved(); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="backups (optional)" + /> + + + + +
+
+ +
+
Use SSL/TLS
+
+ { + if (!storage?.ftpStorage) return; + + setStorage({ + ...storage, + ftpStorage: { + ...storage.ftpStorage, + useSsl: e.target.checked, + }, + }); + setUnsaved(); + }} + > + Enable FTPS + + + + + +
+
+ +
+
setShowAdvanced(!showAdvanced)} + > + Advanced settings + + {showAdvanced ? ( + + ) : ( + + )} +
+
+ + {showAdvanced && ( + <> +
+
Passive mode
+
+ { + if (!storage?.ftpStorage) return; + + setStorage({ + ...storage, + ftpStorage: { + ...storage.ftpStorage, + passiveMode: e.target.checked, + }, + }); + setUnsaved(); + }} + > + Use passive mode + + + + + +
+
+ + {storage?.ftpStorage?.useSsl && ( +
+
Skip TLS verify
+
+ { + if (!storage?.ftpStorage) return; + + setStorage({ + ...storage, + ftpStorage: { + ...storage.ftpStorage, + skipTlsVerify: e.target.checked, + }, + }); + setUnsaved(); + }} + > + Skip certificate verification + + + + + +
+
+ )} + + )} + +
+ + ); +} diff --git a/frontend/src/features/storages/ui/show/ShowStorageComponent.tsx b/frontend/src/features/storages/ui/show/ShowStorageComponent.tsx index 94b5d64..7c8321d 100644 --- a/frontend/src/features/storages/ui/show/ShowStorageComponent.tsx +++ b/frontend/src/features/storages/ui/show/ShowStorageComponent.tsx @@ -2,6 +2,7 @@ import { type Storage, StorageType } from '../../../../entity/storages'; import { getStorageLogoFromType } from '../../../../entity/storages/models/getStorageLogoFromType'; import { getStorageNameFromType } from '../../../../entity/storages/models/getStorageNameFromType'; import { ShowAzureBlobStorageComponent } from './storages/ShowAzureBlobStorageComponent'; +import { ShowFTPStorageComponent } from './storages/ShowFTPStorageComponent'; import { ShowGoogleDriveStorageComponent } from './storages/ShowGoogleDriveStorageComponent'; import { ShowNASStorageComponent } from './storages/ShowNASStorageComponent'; import { ShowS3StorageComponent } from './storages/ShowS3StorageComponent'; @@ -44,6 +45,10 @@ export function ShowStorageComponent({ storage }: Props) { )}
+ +
+ {storage?.type === StorageType.FTP && } +
); } diff --git a/frontend/src/features/storages/ui/show/storages/ShowFTPStorageComponent.tsx b/frontend/src/features/storages/ui/show/storages/ShowFTPStorageComponent.tsx new file mode 100644 index 0000000..b49e919 --- /dev/null +++ b/frontend/src/features/storages/ui/show/storages/ShowFTPStorageComponent.tsx @@ -0,0 +1,55 @@ +import type { Storage } from '../../../../../entity/storages'; + +interface Props { + storage: Storage; +} + +export function ShowFTPStorageComponent({ storage }: Props) { + return ( + <> +
+
Host
+ {storage?.ftpStorage?.host || '-'} +
+ +
+
Port
+ {storage?.ftpStorage?.port || '-'} +
+ +
+
Username
+ {storage?.ftpStorage?.username || '-'} +
+ +
+
Password
+ {'*************'} +
+ +
+
Path
+ {storage?.ftpStorage?.path || '-'} +
+ +
+
Use SSL/TLS
+ {storage?.ftpStorage?.useSsl ? 'Yes' : 'No'} +
+ + {storage?.ftpStorage?.useSsl && storage?.ftpStorage?.skipTlsVerify && ( +
+
Skip TLS
+ Enabled +
+ )} + + {storage?.ftpStorage?.passiveMode === false && ( +
+
Passive mode
+ Disabled +
+ )} + + ); +}