mirror of
https://github.com/wgh136/nysoure.git
synced 2025-12-15 23:41:16 +00:00
feat: add FTP storage functionality with API integration
This commit is contained in:
@@ -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`));
|
||||
}
|
||||
|
||||
@@ -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
3
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
|
||||
|
||||
6
go.sum
6
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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
164
server/storage/ftp.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user