feat: add FTP storage functionality with API integration

This commit is contained in:
2025-11-27 19:45:38 +08:00
parent 762ca44873
commit e671083f09
8 changed files with 415 additions and 0 deletions

View File

@@ -457,6 +457,28 @@ class Network {
);
}
async createFTPStorage(
name: string,
host: string,
username: string,
password: string,
basePath: string,
domain: string,
maxSizeInMB: number,
): Promise<Response<any>> {
return this._callApi(() =>
axios.post(`${this.apiBaseUrl}/storage/ftp`, {
name,
host,
username,
password,
basePath,
domain,
maxSizeInMB,
}),
);
}
async listStorages(): Promise<Response<Storage[]>> {
return this._callApi(() => axios.get(`${this.apiBaseUrl}/storage`));
}

View File

@@ -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);
}}
/>
<input
className="btn"
type="radio"
name="type"
aria-label={t("FTP")}
onInput={() => {
setStorageType(StorageType.ftp);
}}
/>
</form>
{storageType === StorageType.local && (
@@ -525,6 +561,114 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
</>
)}
{storageType === StorageType.ftp && (
<>
<label className="input w-full my-2">
{t("Name")}
<input
type="text"
className="w-full"
value={params.name}
onChange={(e) => {
setParams({
...params,
name: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Host")}
<input
type="text"
placeholder="ftp.example.com:21"
className="w-full"
value={params.host}
onChange={(e) => {
setParams({
...params,
host: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Username")}
<input
type="text"
className="w-full"
value={params.username}
onChange={(e) => {
setParams({
...params,
username: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Password")}
<input
type="password"
className="w-full"
value={params.password}
onChange={(e) => {
setParams({
...params,
password: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Base Path")}
<input
type="text"
placeholder="/uploads"
className="w-full"
value={params.basePath}
onChange={(e) => {
setParams({
...params,
basePath: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Domain")}
<input
type="text"
placeholder="files.example.com"
className="w-full"
value={params.domain}
onChange={(e) => {
setParams({
...params,
domain: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Max Size (MB)")}
<input
type="number"
className="validator"
required
min="0"
value={params.maxSizeInMB.toString()}
onChange={(e) => {
setParams({
...params,
maxSizeInMB: parseInt(e.target.value),
});
}}
/>
</label>
</>
)}
{error !== "" && <ErrorAlert message={error} className={"my-2"} />}
<div className="modal-action">

3
go.mod
View File

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

6
go.sum
View File

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

View File

@@ -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(&params); 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)

View File

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

164
server/storage/ftp.go Normal file
View File

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

View File

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