FEATURE (storanges): Add FTP storange

This commit is contained in:
Rostislav Dugin
2025-12-13 22:16:11 +03:00
parent b666cd9e2e
commit ace94c144b
23 changed files with 893 additions and 4 deletions

View File

@@ -39,4 +39,6 @@ TEST_SUPABASE_HOST=
TEST_SUPABASE_PORT=
TEST_SUPABASE_USERNAME=
TEST_SUPABASE_PASSWORD=
TEST_SUPABASE_DATABASE=
TEST_SUPABASE_DATABASE=
# FTP
TEST_FTP_PORT=7007

View File

@@ -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

View File

@@ -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

View File

@@ -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=

View File

@@ -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"`

View File

@@ -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 {

View File

@@ -8,4 +8,5 @@ const (
StorageTypeGoogleDrive StorageType = "GOOGLE_DRIVE"
StorageTypeNAS StorageType = "NAS"
StorageTypeAzureBlob StorageType = "AZURE_BLOB"
StorageTypeFTP StorageType = "FTP"
)

View File

@@ -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))
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M853.333333 256H469.333333l-85.333333-85.333333H170.666667c-46.933333 0-85.333333 38.4-85.333334 85.333333v170.666667h853.333334v-85.333334c0-46.933333-38.4-85.333333-85.333334-85.333333z" fill="#FFA000" /><path d="M853.333333 256H170.666667c-46.933333 0-85.333333 38.4-85.333334 85.333333v426.666667c0 46.933333 38.4 85.333333 85.333334 85.333333h682.666666c46.933333 0 85.333333-38.4 85.333334-85.333333V341.333333c0-46.933333-38.4-85.333333-85.333334-85.333333z" fill="#FFCA28" /></svg>

After

Width:  |  Height:  |  Size: 741 B

View File

@@ -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';

View File

@@ -0,0 +1,10 @@
export interface FTPStorage {
host: string;
port: number;
username: string;
password: string;
useSsl: boolean;
skipTlsVerify?: boolean;
passiveMode: boolean;
path?: string;
}

View File

@@ -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;
}

View File

@@ -4,4 +4,5 @@ export enum StorageType {
GOOGLE_DRIVE = 'GOOGLE_DRIVE',
NAS = 'NAS',
AZURE_BLOB = 'AZURE_BLOB',
FTP = 'FTP',
}

View File

@@ -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 '';
}

View File

@@ -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 '';
}

View File

@@ -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 && (
<EditFTPStorageComponent
storage={storage}
setStorage={setStorage}
setUnsaved={() => {
setIsUnsaved(true);
setIsTestConnectionSuccess(false);
}}
/>
)}
</div>
<div className="mt-3 flex">

View File

@@ -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 (
<>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Host</div>
<Input
value={storage?.ftpStorage?.host || ''}
onChange={(e) => {
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"
/>
</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Port</div>
<InputNumber
value={storage?.ftpStorage?.port}
onChange={(value) => {
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"
/>
</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Username</div>
<Input
value={storage?.ftpStorage?.username || ''}
onChange={(e) => {
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"
/>
</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Password</div>
<Input.Password
value={storage?.ftpStorage?.password || ''}
onChange={(e) => {
if (!storage?.ftpStorage) return;
setStorage({
...storage,
ftpStorage: {
...storage.ftpStorage,
password: e.target.value,
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="password"
/>
</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Path</div>
<div className="flex items-center">
<Input
value={storage?.ftpStorage?.path || ''}
onChange={(e) => {
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)"
/>
<Tooltip
className="cursor-pointer"
title="Remote directory path for storing backups (optional)"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Use SSL/TLS</div>
<div className="flex items-center">
<Checkbox
checked={storage?.ftpStorage?.useSsl || false}
onChange={(e) => {
if (!storage?.ftpStorage) return;
setStorage({
...storage,
ftpStorage: {
...storage.ftpStorage,
useSsl: e.target.checked,
},
});
setUnsaved();
}}
>
Enable FTPS
</Checkbox>
<Tooltip
className="cursor-pointer"
title="Use explicit TLS encryption (FTPS) for secure file transfer"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
<div className="mt-4 mb-3 flex items-center">
<div
className="flex cursor-pointer items-center text-sm text-blue-600 hover:text-blue-800"
onClick={() => setShowAdvanced(!showAdvanced)}
>
<span className="mr-2">Advanced settings</span>
{showAdvanced ? (
<UpOutlined style={{ fontSize: '12px' }} />
) : (
<DownOutlined style={{ fontSize: '12px' }} />
)}
</div>
</div>
{showAdvanced && (
<>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Passive mode</div>
<div className="flex items-center">
<Checkbox
checked={storage?.ftpStorage?.passiveMode !== false}
onChange={(e) => {
if (!storage?.ftpStorage) return;
setStorage({
...storage,
ftpStorage: {
...storage.ftpStorage,
passiveMode: e.target.checked,
},
});
setUnsaved();
}}
>
Use passive mode
</Checkbox>
<Tooltip
className="cursor-pointer"
title="Passive mode is recommended for most firewalls and NAT configurations. Disable only if you have issues connecting."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
{storage?.ftpStorage?.useSsl && (
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Skip TLS verify</div>
<div className="flex items-center">
<Checkbox
checked={storage?.ftpStorage?.skipTlsVerify || false}
onChange={(e) => {
if (!storage?.ftpStorage) return;
setStorage({
...storage,
ftpStorage: {
...storage.ftpStorage,
skipTlsVerify: e.target.checked,
},
});
setUnsaved();
}}
>
Skip certificate verification
</Checkbox>
<Tooltip
className="cursor-pointer"
title="Skip TLS certificate verification. Enable this if your FTP server uses a self-signed certificate. Warning: this reduces security."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
)}
</>
)}
<div className="mb-5" />
</>
);
}

View File

@@ -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) {
<ShowAzureBlobStorageComponent storage={storage} />
)}
</div>
<div>
{storage?.type === StorageType.FTP && <ShowFTPStorageComponent storage={storage} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import type { Storage } from '../../../../../entity/storages';
interface Props {
storage: Storage;
}
export function ShowFTPStorageComponent({ storage }: Props) {
return (
<>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Host</div>
{storage?.ftpStorage?.host || '-'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Port</div>
{storage?.ftpStorage?.port || '-'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Username</div>
{storage?.ftpStorage?.username || '-'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Password</div>
{'*************'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Path</div>
{storage?.ftpStorage?.path || '-'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Use SSL/TLS</div>
{storage?.ftpStorage?.useSsl ? 'Yes' : 'No'}
</div>
{storage?.ftpStorage?.useSsl && storage?.ftpStorage?.skipTlsVerify && (
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Skip TLS</div>
Enabled
</div>
)}
{storage?.ftpStorage?.passiveMode === false && (
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Passive mode</div>
Disabled
</div>
)}
</>
);
}