From e671083f093bc270a4c6ba39d75c3cccdb595a1f Mon Sep 17 00:00:00 2001 From: nyne Date: Thu, 27 Nov 2025 19:45:38 +0800 Subject: [PATCH] feat: add FTP storage functionality with API integration --- frontend/src/network/network.ts | 22 +++ frontend/src/pages/manage_storage_page.tsx | 144 ++++++++++++++++++ go.mod | 3 + go.sum | 6 + server/api/storage.go | 32 ++++ server/service/storage.go | 36 +++++ server/storage/ftp.go | 164 +++++++++++++++++++++ server/storage/storage.go | 8 + 8 files changed, 415 insertions(+) create mode 100644 server/storage/ftp.go diff --git a/frontend/src/network/network.ts b/frontend/src/network/network.ts index 0199da5..327a214 100644 --- a/frontend/src/network/network.ts +++ b/frontend/src/network/network.ts @@ -457,6 +457,28 @@ class Network { ); } + async createFTPStorage( + name: string, + host: string, + username: string, + password: string, + basePath: string, + domain: string, + maxSizeInMB: number, + ): Promise> { + return this._callApi(() => + axios.post(`${this.apiBaseUrl}/storage/ftp`, { + name, + host, + username, + password, + basePath, + domain, + maxSizeInMB, + }), + ); + } + async listStorages(): Promise> { return this._callApi(() => axios.get(`${this.apiBaseUrl}/storage`)); } diff --git a/frontend/src/pages/manage_storage_page.tsx b/frontend/src/pages/manage_storage_page.tsx index 0824472..9cb56d9 100644 --- a/frontend/src/pages/manage_storage_page.tsx +++ b/frontend/src/pages/manage_storage_page.tsx @@ -244,6 +244,7 @@ export default function StorageView() { enum StorageType { local, s3, + ftp, } function NewStorageDialog({ onAdded }: { onAdded: () => void }) { @@ -259,6 +260,10 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) { bucketName: "", maxSizeInMB: 0, domain: "", + host: "", + username: "", + password: "", + basePath: "", }); const [isLoading, setIsLoading] = useState(false); @@ -305,6 +310,28 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) { params.maxSizeInMB, params.domain, ); + } else if (storageType === StorageType.ftp) { + if ( + params.host === "" || + params.username === "" || + params.password === "" || + params.domain === "" || + params.name === "" || + params.maxSizeInMB <= 0 + ) { + setError(t("All fields are required")); + setIsLoading(false); + return; + } + response = await network.createFTPStorage( + params.name, + params.host, + params.username, + params.password, + params.basePath, + params.domain, + params.maxSizeInMB, + ); } if (response!.success) { @@ -368,6 +395,15 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) { setStorageType(StorageType.s3); }} /> + { + setStorageType(StorageType.ftp); + }} + /> {storageType === StorageType.local && ( @@ -525,6 +561,114 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) { )} + {storageType === StorageType.ftp && ( + <> + + + + + + + + + )} + {error !== "" && }
diff --git a/go.mod b/go.mod index 9cdc652..fc7e642 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,9 @@ require ( github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/golang/protobuf v1.3.2 // indirect github.com/golang/snappy v0.0.1 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jlaffaye/ftp v0.2.0 // indirect github.com/mschoch/smat v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/steveyen/gtreap v0.1.0 // indirect diff --git a/go.sum b/go.sum index 0767b82..c7f15c6 100644 --- a/go.sum +++ b/go.sum @@ -95,6 +95,10 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99 h1:twflg0XRTjwKpxb/jFExr4HGq6on2dEOmnL6FV+fgPw= github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +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/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ikawaha/kagome.ipadic v1.1.2/go.mod h1:DPSBbU0czaJhAb/5uKQZHMc9MTVRpDugJfX+HddPHHg= @@ -103,6 +107,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/jmhodges/levigo v1.0.0 h1:q5EC36kV79HWeTBWsod3mG11EgStG3qArTKcvlksN1U= github.com/jmhodges/levigo v1.0.0/go.mod h1:Q6Qx+uH3RAqyK4rFQroq9RL7mdkABMcfhEI+nNuzMJQ= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= diff --git a/server/api/storage.go b/server/api/storage.go index 9ab8a2c..f1dc5b3 100644 --- a/server/api/storage.go +++ b/server/api/storage.go @@ -69,6 +69,37 @@ func handleCreateLocalStorage(c fiber.Ctx) error { }) } +func handleCreateFTPStorage(c fiber.Ctx) error { + var params service.CreateFTPStorageParams + if err := c.Bind().JSON(¶ms); err != nil { + return model.NewRequestError("Invalid request body") + } + + if params.Name == "" || params.Host == "" || params.Username == "" || + params.Password == "" || params.Domain == "" { + return model.NewRequestError("All fields are required") + } + + if params.MaxSizeInMB <= 0 { + return model.NewRequestError("Max size must be greater than 0") + } + + uid, ok := c.Locals("uid").(uint) + if !ok { + return model.NewUnAuthorizedError("You are not authorized to perform this action") + } + + err := service.CreateFTPStorage(uid, params) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated).JSON(model.Response[any]{ + Success: true, + Message: "FTP storage created successfully", + }) +} + func handleListStorages(c fiber.Ctx) error { storages, err := service.ListStorages() if err != nil { @@ -136,6 +167,7 @@ func AddStorageRoutes(r fiber.Router) { s := r.Group("storage") s.Post("/s3", handleCreateS3Storage) s.Post("/local", handleCreateLocalStorage) + s.Post("/ftp", handleCreateFTPStorage) s.Get("/", handleListStorages) s.Delete("/:id", handleDeleteStorage) s.Put("/:id/default", handleSetDefaultStorage) diff --git a/server/service/storage.go b/server/service/storage.go index 67a2340..b2e3f36 100644 --- a/server/service/storage.go +++ b/server/service/storage.go @@ -78,6 +78,42 @@ func CreateLocalStorage(uid uint, params CreateLocalStorageParams) error { return err } +type CreateFTPStorageParams struct { + Name string `json:"name"` + Host string `json:"host"` + Username string `json:"username"` + Password string `json:"password"` + BasePath string `json:"basePath"` + Domain string `json:"domain"` + MaxSizeInMB uint `json:"maxSizeInMB"` +} + +func CreateFTPStorage(uid uint, params CreateFTPStorageParams) error { + isAdmin, err := CheckUserIsAdmin(uid) + if err != nil { + log.Errorf("check user is admin failed: %s", err) + return model.NewInternalServerError("check user is admin failed") + } + if !isAdmin { + return model.NewUnAuthorizedError("only admin can create ftp storage") + } + ftp := storage.FTPStorage{ + Host: params.Host, + Username: params.Username, + Password: params.Password, + BasePath: params.BasePath, + Domain: params.Domain, + } + s := model.Storage{ + Name: params.Name, + Type: ftp.Type(), + Config: ftp.ToString(), + MaxSize: int64(params.MaxSizeInMB) * 1024 * 1024, + } + _, err = dao.CreateStorage(s) + return err +} + func ListStorages() ([]model.StorageView, error) { storages, err := dao.GetStorages() if err != nil { diff --git a/server/storage/ftp.go b/server/storage/ftp.go new file mode 100644 index 0000000..20d2a1e --- /dev/null +++ b/server/storage/ftp.go @@ -0,0 +1,164 @@ +package storage + +import ( + "encoding/json" + "errors" + "os" + "path" + "time" + + "github.com/gofiber/fiber/v3/log" + "github.com/google/uuid" + "github.com/jlaffaye/ftp" +) + +type FTPStorage struct { + Host string // FTP服务器地址,例如: "ftp.example.com:21" + Username string // FTP用户名 + Password string // FTP密码 + BasePath string // FTP服务器上的基础路径,例如: "/uploads" + Domain string // 文件服务器域名,用于生成下载链接,例如: "files.example.com" +} + +func (f *FTPStorage) Upload(filePath string, fileName string) (string, error) { + // 连接到FTP服务器 + conn, err := ftp.Dial(f.Host, ftp.DialWithTimeout(10*time.Second)) + if err != nil { + log.Error("Failed to connect to FTP server: ", err) + return "", errors.New("failed to connect to FTP server") + } + defer conn.Quit() + + // 登录 + err = conn.Login(f.Username, f.Password) + if err != nil { + log.Error("Failed to login to FTP server: ", err) + return "", errors.New("failed to login to FTP server") + } + + // 生成唯一的存储键 + storageKey := uuid.NewString() + "/" + fileName + remotePath := path.Join(f.BasePath, storageKey) + + // 创建远程目录 + remoteDir := path.Dir(remotePath) + err = f.createRemoteDir(conn, remoteDir) + if err != nil { + log.Error("Failed to create remote directory: ", err) + return "", errors.New("failed to create remote directory") + } + + // 打开本地文件 + file, err := os.Open(filePath) + if err != nil { + log.Error("Failed to open local file: ", err) + return "", errors.New("failed to open local file") + } + defer file.Close() + + // 上传文件 + err = conn.Stor(remotePath, file) + if err != nil { + log.Error("Failed to upload file to FTP server: ", err) + return "", errors.New("failed to upload file to FTP server") + } + + return storageKey, nil +} + +func (f *FTPStorage) Download(storageKey string, fileName string) (string, error) { + // 返回文件下载链接:域名 + 存储键 + if f.Domain == "" { + return "", errors.New("domain is not configured") + } + return "https://" + f.Domain + "/" + storageKey, nil +} + +func (f *FTPStorage) Delete(storageKey string) error { + // 连接到FTP服务器 + conn, err := ftp.Dial(f.Host, ftp.DialWithTimeout(10*time.Second)) + if err != nil { + log.Error("Failed to connect to FTP server: ", err) + return errors.New("failed to connect to FTP server") + } + defer conn.Quit() + + // 登录 + err = conn.Login(f.Username, f.Password) + if err != nil { + log.Error("Failed to login to FTP server: ", err) + return errors.New("failed to login to FTP server") + } + + // 删除文件 + remotePath := path.Join(f.BasePath, storageKey) + err = conn.Delete(remotePath) + if err != nil { + log.Error("Failed to delete file from FTP server: ", err) + return errors.New("failed to delete file from FTP server") + } + + return nil +} + +func (f *FTPStorage) ToString() string { + data, _ := json.Marshal(f) + return string(data) +} + +func (f *FTPStorage) FromString(config string) error { + var ftpConfig FTPStorage + if err := json.Unmarshal([]byte(config), &ftpConfig); err != nil { + return err + } + f.Host = ftpConfig.Host + f.Username = ftpConfig.Username + f.Password = ftpConfig.Password + f.BasePath = ftpConfig.BasePath + f.Domain = ftpConfig.Domain + + if f.Host == "" || f.Username == "" || f.Password == "" || f.Domain == "" { + return errors.New("invalid FTP configuration") + } + if f.BasePath == "" { + f.BasePath = "/" + } + return nil +} + +func (f *FTPStorage) Type() string { + return "ftp" +} + +// createRemoteDir 递归创建远程目录 +func (f *FTPStorage) createRemoteDir(conn *ftp.ServerConn, dirPath string) error { + if dirPath == "" || dirPath == "/" || dirPath == "." { + return nil + } + + // 尝试进入目录,如果失败则创建 + err := conn.ChangeDir(dirPath) + if err == nil { + // 目录存在,返回根目录 + conn.ChangeDir("/") + return nil + } + + // 递归创建父目录 + parentDir := path.Dir(dirPath) + if parentDir != dirPath { + err = f.createRemoteDir(conn, parentDir) + if err != nil { + return err + } + } + + // 创建当前目录 + err = conn.MakeDir(dirPath) + if err != nil { + // 忽略目录已存在的错误 + return nil + } + + return nil +} diff --git a/server/storage/storage.go b/server/storage/storage.go index 50a11e6..8ac675f 100644 --- a/server/storage/storage.go +++ b/server/storage/storage.go @@ -43,6 +43,14 @@ func NewStorage(s model.Storage) IStorage { return nil } return &r + + case "ftp": + r := FTPStorage{} + err := r.FromString(s.Config) + if err != nil { + return nil + } + return &r } return nil }