From 202f77db65638bc3bd84a6506e791a3f141b8c06 Mon Sep 17 00:00:00 2001 From: ZZ0YY <2111479855@qq.com> Date: Fri, 1 May 2026 11:05:16 +0800 Subject: [PATCH 01/12] feat(driver): add Cloudflare Image Bed support --- drivers/all.go | 1 + drivers/cfimgbed/driver.go | 199 +++++++++++++++++++++++++++++++++++++ drivers/cfimgbed/meta.go | 32 ++++++ drivers/cfimgbed/types.go | 146 +++++++++++++++++++++++++++ drivers/cfimgbed/util.go | 3 + go.mod | 2 + public/dist/README.md | 1 - 7 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 drivers/cfimgbed/driver.go create mode 100644 drivers/cfimgbed/meta.go create mode 100644 drivers/cfimgbed/types.go create mode 100644 drivers/cfimgbed/util.go delete mode 100644 public/dist/README.md diff --git a/drivers/all.go b/drivers/all.go index fb68d0395..4031242cd 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -82,6 +82,7 @@ import ( _ "github.com/OpenListTeam/OpenList/v4/drivers/wopan" _ "github.com/OpenListTeam/OpenList/v4/drivers/wps" _ "github.com/OpenListTeam/OpenList/v4/drivers/yandex_disk" + _ "github.com/OpenListTeam/OpenList/v4/drivers/cfimgbed" ) // All do nothing,just for import diff --git a/drivers/cfimgbed/driver.go b/drivers/cfimgbed/driver.go new file mode 100644 index 000000000..a1fe806eb --- /dev/null +++ b/drivers/cfimgbed/driver.go @@ -0,0 +1,199 @@ +package cfimgbed + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/go-resty/resty/v2" +) + +type CFImgBed struct { + model.Storage + Addition + client *resty.Client +} + +func (d *CFImgBed) Config() driver.Config { + return config +} + +func (d *CFImgBed) GetAddition() driver.Additional { + return &d.Addition +} + +// Init initializes the HTTP client with the configured Address and Token. +func (d *CFImgBed) Init(ctx context.Context) error { + d.client = resty.New(). + SetBaseURL(strings.TrimRight(d.Address, "/")). + SetTimeout(30*time.Second). + SetHeader("Authorization", "Bearer "+d.Token). + SetDebug(false) + return nil +} + +func (d *CFImgBed) Drop(ctx context.Context) error { + return nil +} + +// apiError represents a generic error response from the CFImgBed API. +type apiError struct { + Error string `json:"error"` + Message string `json:"message"` +} + +// buildReqPath constructs the path to send to the CFImgBed List API. +// +// OpenList may call List() in two ways: +// 1. List(nil) — initial load of the mount root +// 2. List(obj) — where obj was returned by a previous List() call +// +// When RootPath is set (e.g. "/telegram"), OpenList may pass a virtual root +// dir object whose GetPath() already equals the root path itself. We must +// detect this and avoid double-prepending rootPath. +func buildReqPath(rootPath, dirPath string) string { + rootPath = strings.Trim(rootPath, "/") + dirPath = strings.Trim(dirPath, "/") + + if dirPath == "" || dirPath == rootPath { + // Either listing the real root, or OpenList passed the virtual root dir + return rootPath + } + if rootPath == "" { + return dirPath + } + // dirPath is a subfolder returned by a previous List call, prepend rootPath + return rootPath + "/" + dirPath +} + +// List retrieves the file and directory listing for the given directory. +func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + rootPath := strings.Trim(d.GetRootPath(), "/") + + var dirPath string + if dir != nil { + dirPath = strings.Trim(dir.GetPath(), "/") + } + reqPath := buildReqPath(rootPath, dirPath) + + var resp ListResponse + var errResp apiError + res, err := d.client.R(). + SetQueryParam("dir", reqPath). + SetQueryParam("count", "-1"). + SetResult(&resp). + SetError(&errResp). + Get("/api/manage/list") + + if err != nil { + return nil, err + } + if res.IsError() { + if errResp.Message != "" { + return nil, fmt.Errorf("CFImgBed API error: %s", errResp.Message) + } + return nil, fmt.Errorf("CFImgBed API returned status %d", res.StatusCode()) + } + + objs := make([]model.Obj, 0, len(resp.Directories)+len(resp.Files)) + + // Strip rootPath prefix from returned paths so that GetPath() is relative + // to the OpenList mount point, not the CFImgBed root. + for _, rawDir := range resp.Directories { + cleanDir := strings.TrimRight(rawDir, "/") + p := stripRootPrefix(cleanDir, rootPath) + objs = append(objs, parseDir(p)) + } + + for _, item := range resp.Files { + p := stripRootPrefix(item.Name, rootPath) + objs = append(objs, parseFile(FileItem{ + Name: p, + Metadata: item.Metadata, + })) + } + + return objs, nil +} + +// stripRootPrefix removes the rootPath prefix from a path returned by the API. +// If rootPath is empty or the path doesn't start with rootPath/, return as-is. +func stripRootPrefix(p, rootPath string) string { + if rootPath == "" { + return p + } + prefix := rootPath + "/" + if strings.HasPrefix(p, prefix) { + return strings.TrimPrefix(p, prefix) + } + return p +} + +// Link constructs a direct download URL for the given file object. +// Format: {Address}/file/{rootPath}/{filePath} with no double slashes. +func (d *CFImgBed) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + rootPath := strings.Trim(d.GetRootPath(), "/") + filePath := strings.Trim(file.GetPath(), "/") + + var fullPath string + if rootPath != "" && filePath != "" { + fullPath = rootPath + "/" + filePath + } else if rootPath != "" { + fullPath = rootPath + } else { + fullPath = filePath + } + + link := strings.TrimRight(d.Address, "/") + "/file/" + fullPath + return &model.Link{URL: link}, nil +} + +func (d *CFImgBed) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *CFImgBed) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *CFImgBed) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *CFImgBed) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *CFImgBed) Remove(ctx context.Context, obj model.Obj) error { + return errs.NotImplement +} + +func (d *CFImgBed) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *CFImgBed) GetArchiveMeta(ctx context.Context, obj model.Obj, args model.ArchiveArgs) (model.ArchiveMeta, error) { + return nil, errs.NotImplement +} + +func (d *CFImgBed) ListArchive(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) ([]model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *CFImgBed) Extract(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) (*model.Link, error) { + return nil, errs.NotImplement +} + +func (d *CFImgBed) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, args model.ArchiveDecompressArgs) ([]model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *CFImgBed) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + return nil, errs.NotImplement +} + +var _ driver.Driver = (*CFImgBed)(nil) diff --git a/drivers/cfimgbed/meta.go b/drivers/cfimgbed/meta.go new file mode 100644 index 000000000..d626660f1 --- /dev/null +++ b/drivers/cfimgbed/meta.go @@ -0,0 +1,32 @@ +package cfimgbed + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + driver.RootPath + Address string `json:"address" type:"text" required:"true" default:"" help:"API 域名,如 https://img.example.com"` + Token string `json:"token" type:"text" required:"true" default:"" help:"API 认证 Token"` +} + +var config = driver.Config{ + Name: "CFImgBed", + LocalSort: false, + OnlyProxy: false, + NoCache: false, + NoUpload: true, + NeedMs: false, + DefaultRoot: "/", + CheckStatus: false, + Alert: "", + NoOverwriteUpload: false, + NoLinkURL: false, +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &CFImgBed{} + }) +} diff --git a/drivers/cfimgbed/types.go b/drivers/cfimgbed/types.go new file mode 100644 index 000000000..4d158ee6c --- /dev/null +++ b/drivers/cfimgbed/types.go @@ -0,0 +1,146 @@ +package cfimgbed + +import ( + "fmt" + "path" + "strconv" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" +) + +// File represents a file object parsed from the CFImgBed List API response. +// It implements the model.Obj interface. +type File struct { + Path string + Name_ string + Size_ int64 + ModTime_ time.Time + Mime_ string +} + +func (f *File) GetPath() string { return f.Path } +func (f *File) GetName() string { return f.Name_ } +func (f *File) ModTime() time.Time { return f.ModTime_ } +func (f *File) CreateTime() time.Time { return f.ModTime_ } +func (f *File) GetSize() int64 { return f.Size_ } +func (f *File) IsDir() bool { return false } +func (f *File) GetID() string { return f.Path } +func (f *File) GetHash() utils.HashInfo { return utils.HashInfo{} } + +// Dir represents a directory object parsed from the CFImgBed List API response. +// It implements the model.Obj interface. +type Dir struct { + Path string + Name_ string +} + +func (d *Dir) GetPath() string { return d.Path } +func (d *Dir) GetName() string { return d.Name_ } +func (d *Dir) ModTime() time.Time { return time.Time{} } +func (d *Dir) CreateTime() time.Time { return time.Time{} } +func (d *Dir) GetSize() int64 { return 0 } +func (d *Dir) IsDir() bool { return true } +func (d *Dir) GetID() string { return d.Path } +func (d *Dir) GetHash() utils.HashInfo { return utils.HashInfo{} } + +// Compile-time checks to ensure File and Dir implement model.Obj. +var _ model.Obj = (*File)(nil) +var _ model.Obj = (*Dir)(nil) + +// ListResponse represents the JSON structure returned by the CFImgBed List API. +type ListResponse struct { + Files []FileItem `json:"files"` + Directories []string `json:"directories"` +} + +// FileItem represents a single file entry in the List API response. +// Metadata uses map[string]interface{} because the actual API returns mixed types: +// - TimeStamp: integer (e.g. 1774910085474) in newer versions +// - FileSizeBytes: integer (e.g. 3936071) +// - FileSize: string (e.g. "3.75") — human-readable size +// - FileType: string (e.g. "audio/mpeg") +// - Legacy fields may use string values for numbers +type FileItem struct { + Name string `json:"name"` + Metadata map[string]interface{} `json:"metadata"` +} + +// getString safely extracts a string value from metadata, trying key in order. +func getString(m map[string]interface{}, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok { + switch val := v.(type) { + case string: + return val + case float64: + return strconv.FormatInt(int64(val), 10) + default: + return fmt.Sprintf("%v", val) + } + } + } + return "" +} + +// getInt64 safely extracts an int64 value from metadata, trying key in order. +// Supports string, float64 (JSON number), and int64 types. +func getInt64(m map[string]interface{}, keys ...string) int64 { + for _, k := range keys { + if v, ok := m[k]; ok { + switch val := v.(type) { + case string: + n, _ := strconv.ParseInt(val, 10, 64) + return n + case float64: + return int64(val) + case int64: + return val + } + } + } + return 0 +} + +// parseFile converts an API FileItem to a *File model.Obj. +// It tries multiple key names for each field to handle different API versions: +// - Size: FileSizeBytes (int) > File-Size (string) +// - MIME: FileType > File-Mime +// - Time: TimeStamp (handles both int and string) +func parseFile(item FileItem) *File { + name := path.Base(item.Name) + var size int64 + var modTime time.Time + var mime string + + if item.Metadata != nil { + // Try FileSizeBytes (int) first, fall back to File-Size (string) + size = getInt64(item.Metadata, "FileSizeBytes", "File-Size") + + // Try FileType first, fall back to File-Mime + mime = getString(item.Metadata, "FileType", "File-Mime") + + // TimeStamp may be int or string depending on API version + ts := getInt64(item.Metadata, "TimeStamp") + if ts > 0 { + modTime = time.UnixMilli(ts) + } + } + + return &File{ + Path: item.Name, + Name_: name, + Size_: size, + ModTime_: modTime, + Mime_: mime, + } +} + +// parseDir converts a directory path string from the API to a *Dir model.Obj. +func parseDir(dirPath string) *Dir { + return &Dir{ + Path: dirPath, + Name_: path.Base(dirPath), + } +} diff --git a/drivers/cfimgbed/util.go b/drivers/cfimgbed/util.go new file mode 100644 index 000000000..33ac4ac1a --- /dev/null +++ b/drivers/cfimgbed/util.go @@ -0,0 +1,3 @@ +package cfimgbed + +// do others that not defined in Driver interface diff --git a/go.mod b/go.mod index cd86a8147..a31ab0d73 100644 --- a/go.mod +++ b/go.mod @@ -313,3 +313,5 @@ replace github.com/ProtonMail/go-proton-api => github.com/henrybear327/go-proton replace github.com/cronokirby/saferith => github.com/Da3zKi7/saferith v0.33.0-fixed // replace github.com/OpenListTeam/115-sdk-go => ../../OpenListTeam/115-sdk-go + +replace github.com/OpenListTeam/OpenList/v4/drivers/cfimgbed => ./drivers/cfimgbed diff --git a/public/dist/README.md b/public/dist/README.md deleted file mode 100644 index d8709fb57..000000000 --- a/public/dist/README.md +++ /dev/null @@ -1 +0,0 @@ -## Put dist of frontend here. \ No newline at end of file From 9bdaac8e11a9214932be2d1b7970dc471eac57d2 Mon Sep 17 00:00:00 2001 From: ZZ0YY <2111479855@qq.com> Date: Fri, 1 May 2026 14:00:21 +0800 Subject: [PATCH 02/12] fix: restore accidentally deleted file and name --- public/dist/README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 public/dist/README.md diff --git a/public/dist/README.md b/public/dist/README.md new file mode 100644 index 000000000..d8709fb57 --- /dev/null +++ b/public/dist/README.md @@ -0,0 +1 @@ +## Put dist of frontend here. \ No newline at end of file From f828fc540db50f377a04775a18b833af7cb6a3b6 Mon Sep 17 00:00:00 2001 From: ZZ0YY <2111479855@qq.com> Date: Fri, 1 May 2026 14:03:45 +0800 Subject: [PATCH 03/12] refactor: rename driver to cloudflare_imgbed and fix module structure - Rename driver identifier and directory to 'cloudflare_imgbed' for consistency. - Remove invalid 'replace' directive in go.mod. - Restore accidentally modified/deleted files in public/dist. - Update driver registration in drivers/all.go. Co-authored-by: Copilot --- drivers/all.go | 2 +- drivers/{cfimgbed => cloudflare_imgbed}/driver.go | 2 +- drivers/{cfimgbed => cloudflare_imgbed}/meta.go | 4 ++-- drivers/{cfimgbed => cloudflare_imgbed}/types.go | 2 +- drivers/{cfimgbed => cloudflare_imgbed}/util.go | 2 +- go.mod | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) rename drivers/{cfimgbed => cloudflare_imgbed}/driver.go (99%) rename drivers/{cfimgbed => cloudflare_imgbed}/meta.go (91%) rename drivers/{cfimgbed => cloudflare_imgbed}/types.go (99%) rename drivers/{cfimgbed => cloudflare_imgbed}/util.go (66%) diff --git a/drivers/all.go b/drivers/all.go index 4031242cd..d23e928ac 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -82,7 +82,7 @@ import ( _ "github.com/OpenListTeam/OpenList/v4/drivers/wopan" _ "github.com/OpenListTeam/OpenList/v4/drivers/wps" _ "github.com/OpenListTeam/OpenList/v4/drivers/yandex_disk" - _ "github.com/OpenListTeam/OpenList/v4/drivers/cfimgbed" + _ "github.com/OpenListTeam/OpenList/v4/drivers/cloudflare_imgbed" ) // All do nothing,just for import diff --git a/drivers/cfimgbed/driver.go b/drivers/cloudflare_imgbed/driver.go similarity index 99% rename from drivers/cfimgbed/driver.go rename to drivers/cloudflare_imgbed/driver.go index a1fe806eb..c1886768e 100644 --- a/drivers/cfimgbed/driver.go +++ b/drivers/cloudflare_imgbed/driver.go @@ -1,4 +1,4 @@ -package cfimgbed +package cloudflare_imgbed import ( "context" diff --git a/drivers/cfimgbed/meta.go b/drivers/cloudflare_imgbed/meta.go similarity index 91% rename from drivers/cfimgbed/meta.go rename to drivers/cloudflare_imgbed/meta.go index d626660f1..e065244ce 100644 --- a/drivers/cfimgbed/meta.go +++ b/drivers/cloudflare_imgbed/meta.go @@ -1,4 +1,4 @@ -package cfimgbed +package cloudflare_imgbed import ( "github.com/OpenListTeam/OpenList/v4/internal/driver" @@ -12,7 +12,7 @@ type Addition struct { } var config = driver.Config{ - Name: "CFImgBed", + Name: "cloudflare_imgbed", LocalSort: false, OnlyProxy: false, NoCache: false, diff --git a/drivers/cfimgbed/types.go b/drivers/cloudflare_imgbed/types.go similarity index 99% rename from drivers/cfimgbed/types.go rename to drivers/cloudflare_imgbed/types.go index 4d158ee6c..7e8759c29 100644 --- a/drivers/cfimgbed/types.go +++ b/drivers/cloudflare_imgbed/types.go @@ -1,4 +1,4 @@ -package cfimgbed +package cloudflare_imgbed import ( "fmt" diff --git a/drivers/cfimgbed/util.go b/drivers/cloudflare_imgbed/util.go similarity index 66% rename from drivers/cfimgbed/util.go rename to drivers/cloudflare_imgbed/util.go index 33ac4ac1a..40ac66d77 100644 --- a/drivers/cfimgbed/util.go +++ b/drivers/cloudflare_imgbed/util.go @@ -1,3 +1,3 @@ -package cfimgbed +package cloudflare_imgbed // do others that not defined in Driver interface diff --git a/go.mod b/go.mod index a31ab0d73..b787b1692 100644 --- a/go.mod +++ b/go.mod @@ -314,4 +314,4 @@ replace github.com/cronokirby/saferith => github.com/Da3zKi7/saferith v0.33.0-fi // replace github.com/OpenListTeam/115-sdk-go => ../../OpenListTeam/115-sdk-go -replace github.com/OpenListTeam/OpenList/v4/drivers/cfimgbed => ./drivers/cfimgbed + From 7858f49555639d8585413c27a74ca963cdb172da Mon Sep 17 00:00:00 2001 From: ZZ0YY <2111479855@qq.com> Date: Fri, 1 May 2026 17:48:54 +0800 Subject: [PATCH 04/12] fix: use base.NewRestyClient() and use e.g Co-authored-by: Copilot --- drivers/cloudflare_imgbed/driver.go | 221 ++++++++++++++-------------- drivers/cloudflare_imgbed/meta.go | 38 ++--- 2 files changed, 129 insertions(+), 130 deletions(-) diff --git a/drivers/cloudflare_imgbed/driver.go b/drivers/cloudflare_imgbed/driver.go index c1886768e..06b27ce7b 100644 --- a/drivers/cloudflare_imgbed/driver.go +++ b/drivers/cloudflare_imgbed/driver.go @@ -1,49 +1,48 @@ package cloudflare_imgbed import ( - "context" - "fmt" - "strings" - "time" - - "github.com/OpenListTeam/OpenList/v4/internal/driver" - "github.com/OpenListTeam/OpenList/v4/internal/errs" - "github.com/OpenListTeam/OpenList/v4/internal/model" - "github.com/go-resty/resty/v2" + "context" + "fmt" + "strings" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/go-resty/resty/v2" ) type CFImgBed struct { - model.Storage - Addition - client *resty.Client + model.Storage + Addition + client *resty.Client } func (d *CFImgBed) Config() driver.Config { - return config + return config } func (d *CFImgBed) GetAddition() driver.Additional { - return &d.Addition + return &d.Addition } // Init initializes the HTTP client with the configured Address and Token. func (d *CFImgBed) Init(ctx context.Context) error { - d.client = resty.New(). - SetBaseURL(strings.TrimRight(d.Address, "/")). - SetTimeout(30*time.Second). - SetHeader("Authorization", "Bearer "+d.Token). - SetDebug(false) - return nil + d.client = base.NewRestyClient() + d.client.SetBaseURL(strings.TrimRight(d.Address, "/")). + SetHeader("Authorization", "Bearer "+d.Token). + SetDebug(false) + return nil } func (d *CFImgBed) Drop(ctx context.Context) error { - return nil + return nil } // apiError represents a generic error response from the CFImgBed API. type apiError struct { - Error string `json:"error"` - Message string `json:"message"` + Error string `json:"error"` + Message string `json:"message"` } // buildReqPath constructs the path to send to the CFImgBed List API. @@ -56,144 +55,144 @@ type apiError struct { // dir object whose GetPath() already equals the root path itself. We must // detect this and avoid double-prepending rootPath. func buildReqPath(rootPath, dirPath string) string { - rootPath = strings.Trim(rootPath, "/") - dirPath = strings.Trim(dirPath, "/") + rootPath = strings.Trim(rootPath, "/") + dirPath = strings.Trim(dirPath, "/") - if dirPath == "" || dirPath == rootPath { - // Either listing the real root, or OpenList passed the virtual root dir - return rootPath - } - if rootPath == "" { - return dirPath - } - // dirPath is a subfolder returned by a previous List call, prepend rootPath - return rootPath + "/" + dirPath + if dirPath == "" || dirPath == rootPath { + // Either listing the real root, or OpenList passed the virtual root dir + return rootPath + } + if rootPath == "" { + return dirPath + } + // dirPath is a subfolder returned by a previous List call, prepend rootPath + return rootPath + "/" + dirPath } // List retrieves the file and directory listing for the given directory. func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { - rootPath := strings.Trim(d.GetRootPath(), "/") - - var dirPath string - if dir != nil { - dirPath = strings.Trim(dir.GetPath(), "/") - } - reqPath := buildReqPath(rootPath, dirPath) - - var resp ListResponse - var errResp apiError - res, err := d.client.R(). - SetQueryParam("dir", reqPath). - SetQueryParam("count", "-1"). - SetResult(&resp). - SetError(&errResp). - Get("/api/manage/list") - - if err != nil { - return nil, err - } - if res.IsError() { - if errResp.Message != "" { - return nil, fmt.Errorf("CFImgBed API error: %s", errResp.Message) - } - return nil, fmt.Errorf("CFImgBed API returned status %d", res.StatusCode()) - } - - objs := make([]model.Obj, 0, len(resp.Directories)+len(resp.Files)) - - // Strip rootPath prefix from returned paths so that GetPath() is relative - // to the OpenList mount point, not the CFImgBed root. - for _, rawDir := range resp.Directories { - cleanDir := strings.TrimRight(rawDir, "/") - p := stripRootPrefix(cleanDir, rootPath) - objs = append(objs, parseDir(p)) - } - - for _, item := range resp.Files { - p := stripRootPrefix(item.Name, rootPath) - objs = append(objs, parseFile(FileItem{ - Name: p, - Metadata: item.Metadata, - })) - } - - return objs, nil + rootPath := strings.Trim(d.GetRootPath(), "/") + + var dirPath string + if dir != nil { + dirPath = strings.Trim(dir.GetPath(), "/") + } + reqPath := buildReqPath(rootPath, dirPath) + + var resp ListResponse + var errResp apiError + res, err := d.client.R(). + SetQueryParam("dir", reqPath). + SetQueryParam("count", "-1"). + SetResult(&resp). + SetError(&errResp). + Get("/api/manage/list") + + if err != nil { + return nil, err + } + if res.IsError() { + if errResp.Message != "" { + return nil, fmt.Errorf("CFImgBed API error: %s", errResp.Message) + } + return nil, fmt.Errorf("CFImgBed API returned status %d", res.StatusCode()) + } + + objs := make([]model.Obj, 0, len(resp.Directories)+len(resp.Files)) + + // Strip rootPath prefix from returned paths so that GetPath() is relative + // to the OpenList mount point, not the CFImgBed root. + for _, rawDir := range resp.Directories { + cleanDir := strings.TrimRight(rawDir, "/") + p := stripRootPrefix(cleanDir, rootPath) + objs = append(objs, parseDir(p)) + } + + for _, item := range resp.Files { + p := stripRootPrefix(item.Name, rootPath) + objs = append(objs, parseFile(FileItem{ + Name: p, + Metadata: item.Metadata, + })) + } + + return objs, nil } // stripRootPrefix removes the rootPath prefix from a path returned by the API. // If rootPath is empty or the path doesn't start with rootPath/, return as-is. func stripRootPrefix(p, rootPath string) string { - if rootPath == "" { - return p - } - prefix := rootPath + "/" - if strings.HasPrefix(p, prefix) { - return strings.TrimPrefix(p, prefix) - } - return p + if rootPath == "" { + return p + } + prefix := rootPath + "/" + if strings.HasPrefix(p, prefix) { + return strings.TrimPrefix(p, prefix) + } + return p } // Link constructs a direct download URL for the given file object. // Format: {Address}/file/{rootPath}/{filePath} with no double slashes. func (d *CFImgBed) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { - rootPath := strings.Trim(d.GetRootPath(), "/") - filePath := strings.Trim(file.GetPath(), "/") + rootPath := strings.Trim(d.GetRootPath(), "/") + filePath := strings.Trim(file.GetPath(), "/") - var fullPath string - if rootPath != "" && filePath != "" { - fullPath = rootPath + "/" + filePath - } else if rootPath != "" { - fullPath = rootPath - } else { - fullPath = filePath - } + var fullPath string + if rootPath != "" && filePath != "" { + fullPath = rootPath + "/" + filePath + } else if rootPath != "" { + fullPath = rootPath + } else { + fullPath = filePath + } - link := strings.TrimRight(d.Address, "/") + "/file/" + fullPath - return &model.Link{URL: link}, nil + link := strings.TrimRight(d.Address, "/") + "/file/" + fullPath + return &model.Link{URL: link}, nil } func (d *CFImgBed) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) Remove(ctx context.Context, obj model.Obj) error { - return errs.NotImplement + return errs.NotImplement } func (d *CFImgBed) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) GetArchiveMeta(ctx context.Context, obj model.Obj, args model.ArchiveArgs) (model.ArchiveMeta, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) ListArchive(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) ([]model.Obj, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) Extract(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) (*model.Link, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, args model.ArchiveDecompressArgs) ([]model.Obj, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) GetDetails(ctx context.Context) (*model.StorageDetails, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } var _ driver.Driver = (*CFImgBed)(nil) diff --git a/drivers/cloudflare_imgbed/meta.go b/drivers/cloudflare_imgbed/meta.go index e065244ce..3fa86f5f7 100644 --- a/drivers/cloudflare_imgbed/meta.go +++ b/drivers/cloudflare_imgbed/meta.go @@ -1,32 +1,32 @@ package cloudflare_imgbed import ( - "github.com/OpenListTeam/OpenList/v4/internal/driver" - "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" ) type Addition struct { - driver.RootPath - Address string `json:"address" type:"text" required:"true" default:"" help:"API 域名,如 https://img.example.com"` - Token string `json:"token" type:"text" required:"true" default:"" help:"API 认证 Token"` + driver.RootPath + Address string `json:"address" type:"text" required:"true" default:"" help:"API domain, https://img.example.com"` + Token string `json:"token" type:"text" required:"true" default:"" help:"API authentication token"` } var config = driver.Config{ - Name: "cloudflare_imgbed", - LocalSort: false, - OnlyProxy: false, - NoCache: false, - NoUpload: true, - NeedMs: false, - DefaultRoot: "/", - CheckStatus: false, - Alert: "", - NoOverwriteUpload: false, - NoLinkURL: false, + Name: "cloudflare_imgbed", + LocalSort: false, + OnlyProxy: false, + NoCache: false, + NoUpload: true, + NeedMs: false, + DefaultRoot: "/", + CheckStatus: false, + Alert: "", + NoOverwriteUpload: false, + NoLinkURL: false, } func init() { - op.RegisterDriver(func() driver.Driver { - return &CFImgBed{} - }) + op.RegisterDriver(func() driver.Driver { + return &CFImgBed{} + }) } From 569dedfbce0d7b6dd63bdb4025a5fa0f6e289157 Mon Sep 17 00:00:00 2001 From: ZZ0YY <2111479855@qq.com> Date: Fri, 1 May 2026 17:54:06 +0800 Subject: [PATCH 05/12] fix:go fmt --- drivers/cloudflare_imgbed/driver.go | 220 ++++++++++++++-------------- drivers/cloudflare_imgbed/meta.go | 38 ++--- 2 files changed, 129 insertions(+), 129 deletions(-) diff --git a/drivers/cloudflare_imgbed/driver.go b/drivers/cloudflare_imgbed/driver.go index 06b27ce7b..8d0afcca5 100644 --- a/drivers/cloudflare_imgbed/driver.go +++ b/drivers/cloudflare_imgbed/driver.go @@ -1,48 +1,48 @@ package cloudflare_imgbed import ( - "context" - "fmt" - "strings" - - "github.com/OpenListTeam/OpenList/v4/drivers/base" - "github.com/OpenListTeam/OpenList/v4/internal/driver" - "github.com/OpenListTeam/OpenList/v4/internal/errs" - "github.com/OpenListTeam/OpenList/v4/internal/model" - "github.com/go-resty/resty/v2" + "context" + "fmt" + "strings" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/go-resty/resty/v2" ) type CFImgBed struct { - model.Storage - Addition - client *resty.Client + model.Storage + Addition + client *resty.Client } func (d *CFImgBed) Config() driver.Config { - return config + return config } func (d *CFImgBed) GetAddition() driver.Additional { - return &d.Addition + return &d.Addition } // Init initializes the HTTP client with the configured Address and Token. func (d *CFImgBed) Init(ctx context.Context) error { - d.client = base.NewRestyClient() - d.client.SetBaseURL(strings.TrimRight(d.Address, "/")). - SetHeader("Authorization", "Bearer "+d.Token). - SetDebug(false) - return nil + d.client = base.NewRestyClient() + d.client.SetBaseURL(strings.TrimRight(d.Address, "/")). + SetHeader("Authorization", "Bearer "+d.Token). + SetDebug(false) + return nil } func (d *CFImgBed) Drop(ctx context.Context) error { - return nil + return nil } // apiError represents a generic error response from the CFImgBed API. type apiError struct { - Error string `json:"error"` - Message string `json:"message"` + Error string `json:"error"` + Message string `json:"message"` } // buildReqPath constructs the path to send to the CFImgBed List API. @@ -55,144 +55,144 @@ type apiError struct { // dir object whose GetPath() already equals the root path itself. We must // detect this and avoid double-prepending rootPath. func buildReqPath(rootPath, dirPath string) string { - rootPath = strings.Trim(rootPath, "/") - dirPath = strings.Trim(dirPath, "/") + rootPath = strings.Trim(rootPath, "/") + dirPath = strings.Trim(dirPath, "/") - if dirPath == "" || dirPath == rootPath { - // Either listing the real root, or OpenList passed the virtual root dir - return rootPath - } - if rootPath == "" { - return dirPath - } - // dirPath is a subfolder returned by a previous List call, prepend rootPath - return rootPath + "/" + dirPath + if dirPath == "" || dirPath == rootPath { + // Either listing the real root, or OpenList passed the virtual root dir + return rootPath + } + if rootPath == "" { + return dirPath + } + // dirPath is a subfolder returned by a previous List call, prepend rootPath + return rootPath + "/" + dirPath } // List retrieves the file and directory listing for the given directory. func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { - rootPath := strings.Trim(d.GetRootPath(), "/") - - var dirPath string - if dir != nil { - dirPath = strings.Trim(dir.GetPath(), "/") - } - reqPath := buildReqPath(rootPath, dirPath) - - var resp ListResponse - var errResp apiError - res, err := d.client.R(). - SetQueryParam("dir", reqPath). - SetQueryParam("count", "-1"). - SetResult(&resp). - SetError(&errResp). - Get("/api/manage/list") - - if err != nil { - return nil, err - } - if res.IsError() { - if errResp.Message != "" { - return nil, fmt.Errorf("CFImgBed API error: %s", errResp.Message) - } - return nil, fmt.Errorf("CFImgBed API returned status %d", res.StatusCode()) - } - - objs := make([]model.Obj, 0, len(resp.Directories)+len(resp.Files)) - - // Strip rootPath prefix from returned paths so that GetPath() is relative - // to the OpenList mount point, not the CFImgBed root. - for _, rawDir := range resp.Directories { - cleanDir := strings.TrimRight(rawDir, "/") - p := stripRootPrefix(cleanDir, rootPath) - objs = append(objs, parseDir(p)) - } - - for _, item := range resp.Files { - p := stripRootPrefix(item.Name, rootPath) - objs = append(objs, parseFile(FileItem{ - Name: p, - Metadata: item.Metadata, - })) - } - - return objs, nil + rootPath := strings.Trim(d.GetRootPath(), "/") + + var dirPath string + if dir != nil { + dirPath = strings.Trim(dir.GetPath(), "/") + } + reqPath := buildReqPath(rootPath, dirPath) + + var resp ListResponse + var errResp apiError + res, err := d.client.R(). + SetQueryParam("dir", reqPath). + SetQueryParam("count", "-1"). + SetResult(&resp). + SetError(&errResp). + Get("/api/manage/list") + + if err != nil { + return nil, err + } + if res.IsError() { + if errResp.Message != "" { + return nil, fmt.Errorf("CFImgBed API error: %s", errResp.Message) + } + return nil, fmt.Errorf("CFImgBed API returned status %d", res.StatusCode()) + } + + objs := make([]model.Obj, 0, len(resp.Directories)+len(resp.Files)) + + // Strip rootPath prefix from returned paths so that GetPath() is relative + // to the OpenList mount point, not the CFImgBed root. + for _, rawDir := range resp.Directories { + cleanDir := strings.TrimRight(rawDir, "/") + p := stripRootPrefix(cleanDir, rootPath) + objs = append(objs, parseDir(p)) + } + + for _, item := range resp.Files { + p := stripRootPrefix(item.Name, rootPath) + objs = append(objs, parseFile(FileItem{ + Name: p, + Metadata: item.Metadata, + })) + } + + return objs, nil } // stripRootPrefix removes the rootPath prefix from a path returned by the API. // If rootPath is empty or the path doesn't start with rootPath/, return as-is. func stripRootPrefix(p, rootPath string) string { - if rootPath == "" { - return p - } - prefix := rootPath + "/" - if strings.HasPrefix(p, prefix) { - return strings.TrimPrefix(p, prefix) - } - return p + if rootPath == "" { + return p + } + prefix := rootPath + "/" + if strings.HasPrefix(p, prefix) { + return strings.TrimPrefix(p, prefix) + } + return p } // Link constructs a direct download URL for the given file object. // Format: {Address}/file/{rootPath}/{filePath} with no double slashes. func (d *CFImgBed) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { - rootPath := strings.Trim(d.GetRootPath(), "/") - filePath := strings.Trim(file.GetPath(), "/") + rootPath := strings.Trim(d.GetRootPath(), "/") + filePath := strings.Trim(file.GetPath(), "/") - var fullPath string - if rootPath != "" && filePath != "" { - fullPath = rootPath + "/" + filePath - } else if rootPath != "" { - fullPath = rootPath - } else { - fullPath = filePath - } + var fullPath string + if rootPath != "" && filePath != "" { + fullPath = rootPath + "/" + filePath + } else if rootPath != "" { + fullPath = rootPath + } else { + fullPath = filePath + } - link := strings.TrimRight(d.Address, "/") + "/file/" + fullPath - return &model.Link{URL: link}, nil + link := strings.TrimRight(d.Address, "/") + "/file/" + fullPath + return &model.Link{URL: link}, nil } func (d *CFImgBed) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) Remove(ctx context.Context, obj model.Obj) error { - return errs.NotImplement + return errs.NotImplement } func (d *CFImgBed) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) GetArchiveMeta(ctx context.Context, obj model.Obj, args model.ArchiveArgs) (model.ArchiveMeta, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) ListArchive(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) ([]model.Obj, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) Extract(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) (*model.Link, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, args model.ArchiveDecompressArgs) ([]model.Obj, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } func (d *CFImgBed) GetDetails(ctx context.Context) (*model.StorageDetails, error) { - return nil, errs.NotImplement + return nil, errs.NotImplement } var _ driver.Driver = (*CFImgBed)(nil) diff --git a/drivers/cloudflare_imgbed/meta.go b/drivers/cloudflare_imgbed/meta.go index 3fa86f5f7..f151d9f6b 100644 --- a/drivers/cloudflare_imgbed/meta.go +++ b/drivers/cloudflare_imgbed/meta.go @@ -1,32 +1,32 @@ package cloudflare_imgbed import ( - "github.com/OpenListTeam/OpenList/v4/internal/driver" - "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" ) type Addition struct { - driver.RootPath - Address string `json:"address" type:"text" required:"true" default:"" help:"API domain, https://img.example.com"` - Token string `json:"token" type:"text" required:"true" default:"" help:"API authentication token"` + driver.RootPath + Address string `json:"address" type:"text" required:"true" default:"" help:"API domain, https://img.example.com"` + Token string `json:"token" type:"text" required:"true" default:"" help:"API authentication token"` } var config = driver.Config{ - Name: "cloudflare_imgbed", - LocalSort: false, - OnlyProxy: false, - NoCache: false, - NoUpload: true, - NeedMs: false, - DefaultRoot: "/", - CheckStatus: false, - Alert: "", - NoOverwriteUpload: false, - NoLinkURL: false, + Name: "cloudflare_imgbed", + LocalSort: false, + OnlyProxy: false, + NoCache: false, + NoUpload: true, + NeedMs: false, + DefaultRoot: "/", + CheckStatus: false, + Alert: "", + NoOverwriteUpload: false, + NoLinkURL: false, } func init() { - op.RegisterDriver(func() driver.Driver { - return &CFImgBed{} - }) + op.RegisterDriver(func() driver.Driver { + return &CFImgBed{} + }) } From 36aecbfd32f5adf6e7cd4fc4d28e34202c907b9f Mon Sep 17 00:00:00 2001 From: ZZ0YY <2111479855@qq.com> Date: Sat, 2 May 2026 01:54:49 +0800 Subject: [PATCH 06/12] feat(driver/cloudflare-imgbed): enhance cloudflare_imgbed API integration with improved error handling and pagination --- drivers/cloudflare_imgbed/driver.go | 126 +++++++++++++++++----------- drivers/cloudflare_imgbed/meta.go | 1 + drivers/cloudflare_imgbed/types.go | 100 +++++++++++----------- 3 files changed, 129 insertions(+), 98 deletions(-) diff --git a/drivers/cloudflare_imgbed/driver.go b/drivers/cloudflare_imgbed/driver.go index 8d0afcca5..0674c1130 100644 --- a/drivers/cloudflare_imgbed/driver.go +++ b/drivers/cloudflare_imgbed/driver.go @@ -9,6 +9,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/driver" "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/go-resty/resty/v2" ) @@ -26,7 +27,8 @@ func (d *CFImgBed) GetAddition() driver.Additional { return &d.Addition } -// Init initializes the HTTP client with the configured Address and Token. +// Init 使用 base 包提供的工厂方法初始化 HTTP 客户端, +// 并设置 API 基础地址和鉴权请求头。 func (d *CFImgBed) Init(ctx context.Context) error { d.client = base.NewRestyClient() d.client.SetBaseURL(strings.TrimRight(d.Address, "/")). @@ -39,37 +41,41 @@ func (d *CFImgBed) Drop(ctx context.Context) error { return nil } -// apiError represents a generic error response from the CFImgBed API. +// apiError 表示 CFImgBed API 返回的通用错误响应结构。 type apiError struct { Error string `json:"error"` Message string `json:"message"` } -// buildReqPath constructs the path to send to the CFImgBed List API. +// buildReqPath 根据挂载根路径和当前浏览目录,拼接出发送给 API 的请求路径。 // -// OpenList may call List() in two ways: -// 1. List(nil) — initial load of the mount root -// 2. List(obj) — where obj was returned by a previous List() call +// OpenList 可能在两种场景下调用 List: +// 1. List(nil) — 首次加载挂载点根目录 +// 2. List(obj) — 用户点击进入某个子目录,obj 由上一次 List 返回 // -// When RootPath is set (e.g. "/telegram"), OpenList may pass a virtual root -// dir object whose GetPath() already equals the root path itself. We must -// detect this and avoid double-prepending rootPath. +// 当设置了 RootPath(如 "/telegram")时,OpenList 首次调用的 dir 对象 +// 的 GetPath() 可能已经等于 rootPath 本身,此时不应重复拼接前缀。 func buildReqPath(rootPath, dirPath string) string { rootPath = strings.Trim(rootPath, "/") dirPath = strings.Trim(dirPath, "/") if dirPath == "" || dirPath == rootPath { - // Either listing the real root, or OpenList passed the virtual root dir + // 正在浏览根目录,或 OpenList 传入了虚拟根目录对象 return rootPath } if rootPath == "" { + // 未设置挂载前缀,直接使用目录路径 return dirPath } - // dirPath is a subfolder returned by a previous List call, prepend rootPath + // 正常子目录:在目录路径前补上挂载根路径 return rootPath + "/" + dirPath } -// List retrieves the file and directory listing for the given directory. +// List 获取指定目录下的文件和子目录列表。 +// +// 采用内部分页循环拉取,以防止单目录文件过多导致 API 响应超时或内存异常。 +// 每次请求 listPageSize 条记录,直到返回数量不足一页时退出循环, +// 最终将所有分页结果汇总后一次性返回给 OpenList。 func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { rootPath := strings.Trim(d.GetRootPath(), "/") @@ -79,48 +85,69 @@ func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) } reqPath := buildReqPath(rootPath, dirPath) - var resp ListResponse - var errResp apiError - res, err := d.client.R(). - SetQueryParam("dir", reqPath). - SetQueryParam("count", "-1"). - SetResult(&resp). - SetError(&errResp). - Get("/api/manage/list") - - if err != nil { - return nil, err - } - if res.IsError() { - if errResp.Message != "" { - return nil, fmt.Errorf("CFImgBed API error: %s", errResp.Message) + // 用于去重:API 在分页时每个页面都可能重复返回相同的目录列表, + // 使用 map 确保同一个目录对象只被添加一次。 + dirSeen := make(map[string]bool) + objs := make([]model.Obj, 0) + + // 分页拉取循环 + start := 0 + for { + var resp ListResponse + var errResp apiError + res, err := d.client.R(). + SetQueryParam("dir", reqPath). + SetQueryParam("start", fmt.Sprintf("%d", start)). + SetQueryParam("count", fmt.Sprintf("%d", listPageSize)). + SetResult(&resp). + SetError(&errResp). + Get("/api/manage/list") + + if err != nil { + return nil, err + } + if res.IsError() { + if errResp.Message != "" { + return nil, fmt.Errorf("CFImgBed API error: %s", errResp.Message) + } + return nil, fmt.Errorf("CFImgBed API returned status %d", res.StatusCode()) } - return nil, fmt.Errorf("CFImgBed API returned status %d", res.StatusCode()) - } - objs := make([]model.Obj, 0, len(resp.Directories)+len(resp.Files)) + // 裁剪 API 返回路径中的挂载根前缀, + // 使 GetPath() 返回的是相对于 OpenList 挂载点的路径,而非图床的绝对路径。 + for _, rawDir := range resp.Directories { + cleanDir := strings.TrimRight(rawDir, "/") + p := stripRootPrefix(cleanDir, rootPath) + // 目录去重:分页场景下不同页面可能返回相同的目录条目 + if !dirSeen[p] { + dirSeen[p] = true + objs = append(objs, parseDir(p)) + } + } - // Strip rootPath prefix from returned paths so that GetPath() is relative - // to the OpenList mount point, not the CFImgBed root. - for _, rawDir := range resp.Directories { - cleanDir := strings.TrimRight(rawDir, "/") - p := stripRootPrefix(cleanDir, rootPath) - objs = append(objs, parseDir(p)) - } + for _, item := range resp.Files { + p := stripRootPrefix(item.Name, rootPath) + objs = append(objs, parseFile(FileItem{ + Name: p, + Metadata: item.Metadata, + })) + } + + // 判断是否已到最后一页:当返回的文件和目录总数小于请求的每页数量时, + // 说明本页已经是最后一页,无需继续请求。 + fetched := len(resp.Files) + len(resp.Directories) + if fetched < listPageSize { + break + } - for _, item := range resp.Files { - p := stripRootPrefix(item.Name, rootPath) - objs = append(objs, parseFile(FileItem{ - Name: p, - Metadata: item.Metadata, - })) + start += listPageSize } return objs, nil } -// stripRootPrefix removes the rootPath prefix from a path returned by the API. -// If rootPath is empty or the path doesn't start with rootPath/, return as-is. +// stripRootPrefix 移除 API 返回路径中的挂载根前缀。 +// 如果未设置 rootPath 或路径不以 rootPath/ 开头,则原样返回。 func stripRootPrefix(p, rootPath string) string { if rootPath == "" { return p @@ -132,12 +159,13 @@ func stripRootPrefix(p, rootPath string) string { return p } -// Link constructs a direct download URL for the given file object. -// Format: {Address}/file/{rootPath}/{filePath} with no double slashes. +// Link 拼装文件的直接下载/访问链接。 +// 路径中可能包含空格、中文、#、+ 等特殊字符,必须进行安全编码以生成有效 URL。 func (d *CFImgBed) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { rootPath := strings.Trim(d.GetRootPath(), "/") filePath := strings.Trim(file.GetPath(), "/") + // 拼接完整路径,避免出现双斜杠 var fullPath string if rootPath != "" && filePath != "" { fullPath = rootPath + "/" + filePath @@ -147,7 +175,8 @@ func (d *CFImgBed) Link(ctx context.Context, file model.Obj, args model.LinkArgs fullPath = filePath } - link := strings.TrimRight(d.Address, "/") + "/file/" + fullPath + // 对路径进行安全编码,处理空格、特殊字符等可能导致链接失效的情况 + link := strings.TrimRight(d.Address, "/") + "/file/" + utils.EncodePath(fullPath) return &model.Link{URL: link}, nil } @@ -195,4 +224,5 @@ func (d *CFImgBed) GetDetails(ctx context.Context) (*model.StorageDetails, error return nil, errs.NotImplement } +// 编译时检查 CFImgBed 是否完整实现 driver.Driver 接口。 var _ driver.Driver = (*CFImgBed)(nil) diff --git a/drivers/cloudflare_imgbed/meta.go b/drivers/cloudflare_imgbed/meta.go index f151d9f6b..e8aa170b5 100644 --- a/drivers/cloudflare_imgbed/meta.go +++ b/drivers/cloudflare_imgbed/meta.go @@ -5,6 +5,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/op" ) +// Addition 定义驱动在 OpenList 前端管理界面中显示的表单配置项。 type Addition struct { driver.RootPath Address string `json:"address" type:"text" required:"true" default:"" help:"API domain, https://img.example.com"` diff --git a/drivers/cloudflare_imgbed/types.go b/drivers/cloudflare_imgbed/types.go index 7e8759c29..d8f52d2aa 100644 --- a/drivers/cloudflare_imgbed/types.go +++ b/drivers/cloudflare_imgbed/types.go @@ -10,64 +10,67 @@ import ( "github.com/OpenListTeam/OpenList/v4/pkg/utils" ) -// File represents a file object parsed from the CFImgBed List API response. -// It implements the model.Obj interface. +// File 表示从 CFImgBed 列表 API 响应中解析出的文件对象,实现 model.Obj 接口。 type File struct { - Path string - Name_ string - Size_ int64 - ModTime_ time.Time - Mime_ string + path string // 文件相对路径,如 "example/image.jpg" + name string // 显示名称(路径最后一段),如 "image.jpg" + size int64 // 文件大小(字节) + modTime time.Time // 最后修改时间(从 Unix 毫秒时间戳转换而来) + mime string // MIME 类型,如 "image/jpeg" } -func (f *File) GetPath() string { return f.Path } -func (f *File) GetName() string { return f.Name_ } -func (f *File) ModTime() time.Time { return f.ModTime_ } -func (f *File) CreateTime() time.Time { return f.ModTime_ } -func (f *File) GetSize() int64 { return f.Size_ } +func (f *File) GetPath() string { return f.path } +func (f *File) GetName() string { return f.name } +func (f *File) ModTime() time.Time { return f.modTime } +func (f *File) CreateTime() time.Time { return f.modTime } +func (f *File) GetSize() int64 { return f.size } func (f *File) IsDir() bool { return false } -func (f *File) GetID() string { return f.Path } +func (f *File) GetID() string { return f.path } func (f *File) GetHash() utils.HashInfo { return utils.HashInfo{} } -// Dir represents a directory object parsed from the CFImgBed List API response. -// It implements the model.Obj interface. +// Dir 表示从 CFImgBed 列表 API 响应中解析出的目录对象,实现 model.Obj 接口。 type Dir struct { - Path string - Name_ string + path string // 目录相对路径,如 "example/subfolder" + name string // 显示名称(路径最后一段),如 "subfolder" } -func (d *Dir) GetPath() string { return d.Path } -func (d *Dir) GetName() string { return d.Name_ } +func (d *Dir) GetPath() string { return d.path } +func (d *Dir) GetName() string { return d.name } func (d *Dir) ModTime() time.Time { return time.Time{} } func (d *Dir) CreateTime() time.Time { return time.Time{} } func (d *Dir) GetSize() int64 { return 0 } func (d *Dir) IsDir() bool { return true } -func (d *Dir) GetID() string { return d.Path } +func (d *Dir) GetID() string { return d.path } func (d *Dir) GetHash() utils.HashInfo { return utils.HashInfo{} } -// Compile-time checks to ensure File and Dir implement model.Obj. +// 编译时检查 File 和 Dir 是否完整实现 model.Obj 接口。 var _ model.Obj = (*File)(nil) var _ model.Obj = (*Dir)(nil) -// ListResponse represents the JSON structure returned by the CFImgBed List API. +// listPageSize 定义每次向 API 请求的最大条目数。 +// 采用内部分页循环拉取,以防止单目录文件过多导致 API 响应超时或内存异常。 +const listPageSize = 1000 + +// ListResponse 表示 CFImgBed 列表 API 返回的 JSON 结构。 type ListResponse struct { Files []FileItem `json:"files"` Directories []string `json:"directories"` } -// FileItem represents a single file entry in the List API response. -// Metadata uses map[string]interface{} because the actual API returns mixed types: -// - TimeStamp: integer (e.g. 1774910085474) in newer versions -// - FileSizeBytes: integer (e.g. 3936071) -// - FileSize: string (e.g. "3.75") — human-readable size -// - FileType: string (e.g. "audio/mpeg") -// - Legacy fields may use string values for numbers +// FileItem 表示列表 API 返回的单个文件条目。 +// 注意:Metadata 使用 map[string]interface{} 而非 map[string]string, +// 因为实际 API 返回的字段类型不统一: +// - TimeStamp: 可能是整数(如 1774910085474),也可能在旧版本中是字符串 +// - FileSizeBytes: 整数(如 3936071) +// - FileSize: 字符串(如 "3.75")— 仅供人类阅读的格式化大小 +// - FileType: 字符串(如 "audio/mpeg") type FileItem struct { Name string `json:"name"` Metadata map[string]interface{} `json:"metadata"` } -// getString safely extracts a string value from metadata, trying key in order. +// getString 从 metadata 中安全提取字符串值,按 keys 顺序依次尝试。 +// 支持 string 和 float64(JSON 数字反序列化后的默认类型)两种输入。 func getString(m map[string]interface{}, keys ...string) string { for _, k := range keys { if v, ok := m[k]; ok { @@ -84,8 +87,9 @@ func getString(m map[string]interface{}, keys ...string) string { return "" } -// getInt64 safely extracts an int64 value from metadata, trying key in order. -// Supports string, float64 (JSON number), and int64 types. +// getInt64 从 metadata 中安全提取 int64 值,按 keys 顺序依次尝试。 +// 同时兼容 string、float64(JSON 数字)和 int64 三种反序列化类型, +// 确保在不同 API 版本下均能正确解析。 func getInt64(m map[string]interface{}, keys ...string) int64 { for _, k := range keys { if v, ok := m[k]; ok { @@ -103,11 +107,11 @@ func getInt64(m map[string]interface{}, keys ...string) int64 { return 0 } -// parseFile converts an API FileItem to a *File model.Obj. -// It tries multiple key names for each field to handle different API versions: -// - Size: FileSizeBytes (int) > File-Size (string) -// - MIME: FileType > File-Mime -// - Time: TimeStamp (handles both int and string) +// parseFile 将 API 返回的 FileItem 转换为 *File 对象。 +// 字段提取策略(兼容新旧 API 版本): +// - 文件大小:优先取 FileSizeBytes(int),回退到 File-Size(string) +// - MIME 类型:优先取 FileType,回退到 File-Mime +// - 修改时间:取 TimeStamp(同时处理 int 和 string 两种格式) func parseFile(item FileItem) *File { name := path.Base(item.Name) var size int64 @@ -115,13 +119,8 @@ func parseFile(item FileItem) *File { var mime string if item.Metadata != nil { - // Try FileSizeBytes (int) first, fall back to File-Size (string) size = getInt64(item.Metadata, "FileSizeBytes", "File-Size") - - // Try FileType first, fall back to File-Mime mime = getString(item.Metadata, "FileType", "File-Mime") - - // TimeStamp may be int or string depending on API version ts := getInt64(item.Metadata, "TimeStamp") if ts > 0 { modTime = time.UnixMilli(ts) @@ -129,18 +128,19 @@ func parseFile(item FileItem) *File { } return &File{ - Path: item.Name, - Name_: name, - Size_: size, - ModTime_: modTime, - Mime_: mime, + path: item.Name, + name: name, + size: size, + modTime: modTime, + mime: mime, } } -// parseDir converts a directory path string from the API to a *Dir model.Obj. +// parseDir 将 API 返回的目录路径字符串转换为 *Dir 对象。 +// 显示名称取路径的最后一段(即最深层目录名)。 func parseDir(dirPath string) *Dir { return &Dir{ - Path: dirPath, - Name_: path.Base(dirPath), + path: dirPath, + name: path.Base(dirPath), } } From dc74222761e2382f9fd7726d534e5bf384f699f4 Mon Sep 17 00:00:00 2001 From: j2rong4cn Date: Sun, 3 May 2026 15:54:41 +0800 Subject: [PATCH 07/12] refactor --- drivers/cloudflare_imgbed/driver.go | 136 ++++------------------------ drivers/cloudflare_imgbed/meta.go | 15 +-- drivers/cloudflare_imgbed/types.go | 123 ------------------------- drivers/cloudflare_imgbed/util.go | 75 ++++++++++++++- 4 files changed, 98 insertions(+), 251 deletions(-) diff --git a/drivers/cloudflare_imgbed/driver.go b/drivers/cloudflare_imgbed/driver.go index 0674c1130..53b776384 100644 --- a/drivers/cloudflare_imgbed/driver.go +++ b/drivers/cloudflare_imgbed/driver.go @@ -3,11 +3,11 @@ package cloudflare_imgbed import ( "context" "fmt" + stdpath "path" "strings" "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/internal/driver" - "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/go-resty/resty/v2" @@ -30,8 +30,9 @@ func (d *CFImgBed) GetAddition() driver.Additional { // Init 使用 base 包提供的工厂方法初始化 HTTP 客户端, // 并设置 API 基础地址和鉴权请求头。 func (d *CFImgBed) Init(ctx context.Context) error { + d.Address = strings.TrimRight(d.Address, "/") d.client = base.NewRestyClient() - d.client.SetBaseURL(strings.TrimRight(d.Address, "/")). + d.client.SetBaseURL(d.Address). SetHeader("Authorization", "Bearer "+d.Token). SetDebug(false) return nil @@ -47,47 +48,17 @@ type apiError struct { Message string `json:"message"` } -// buildReqPath 根据挂载根路径和当前浏览目录,拼接出发送给 API 的请求路径。 -// -// OpenList 可能在两种场景下调用 List: -// 1. List(nil) — 首次加载挂载点根目录 -// 2. List(obj) — 用户点击进入某个子目录,obj 由上一次 List 返回 -// -// 当设置了 RootPath(如 "/telegram")时,OpenList 首次调用的 dir 对象 -// 的 GetPath() 可能已经等于 rootPath 本身,此时不应重复拼接前缀。 -func buildReqPath(rootPath, dirPath string) string { - rootPath = strings.Trim(rootPath, "/") - dirPath = strings.Trim(dirPath, "/") - - if dirPath == "" || dirPath == rootPath { - // 正在浏览根目录,或 OpenList 传入了虚拟根目录对象 - return rootPath - } - if rootPath == "" { - // 未设置挂载前缀,直接使用目录路径 - return dirPath - } - // 正常子目录:在目录路径前补上挂载根路径 - return rootPath + "/" + dirPath -} - // List 获取指定目录下的文件和子目录列表。 // // 采用内部分页循环拉取,以防止单目录文件过多导致 API 响应超时或内存异常。 // 每次请求 listPageSize 条记录,直到返回数量不足一页时退出循环, // 最终将所有分页结果汇总后一次性返回给 OpenList。 func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { - rootPath := strings.Trim(d.GetRootPath(), "/") - - var dirPath string - if dir != nil { - dirPath = strings.Trim(dir.GetPath(), "/") - } - reqPath := buildReqPath(rootPath, dirPath) + reqPath := dir.GetPath() // 用于去重:API 在分页时每个页面都可能重复返回相同的目录列表, - // 使用 map 确保同一个目录对象只被添加一次。 - dirSeen := make(map[string]bool) + // 确保同一个目录对象只被添加一次。 + dirSeen := ":" objs := make([]model.Obj, 0) // 分页拉取循环 @@ -116,21 +87,24 @@ func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) // 裁剪 API 返回路径中的挂载根前缀, // 使 GetPath() 返回的是相对于 OpenList 挂载点的路径,而非图床的绝对路径。 for _, rawDir := range resp.Directories { - cleanDir := strings.TrimRight(rawDir, "/") - p := stripRootPrefix(cleanDir, rootPath) + p := strings.TrimRight(rawDir, "/") // 目录去重:分页场景下不同页面可能返回相同的目录条目 - if !dirSeen[p] { - dirSeen[p] = true - objs = append(objs, parseDir(p)) + if !strings.Contains(dirSeen, ":"+p+":") { + dirSeen += p + ":" + name := stdpath.Base(p) + objs = append(objs, &model.Object{ + Name: name, + Path: stdpath.Join(reqPath, name), + Modified: d.Modified, + IsFolder: true, + }) } } for _, item := range resp.Files { - p := stripRootPrefix(item.Name, rootPath) - objs = append(objs, parseFile(FileItem{ - Name: p, - Metadata: item.Metadata, - })) + obj := parseFile(item) + obj.Path = stdpath.Join(reqPath, obj.Name) + objs = append(objs, obj) } // 判断是否已到最后一页:当返回的文件和目录总数小于请求的每页数量时, @@ -146,83 +120,13 @@ func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) return objs, nil } -// stripRootPrefix 移除 API 返回路径中的挂载根前缀。 -// 如果未设置 rootPath 或路径不以 rootPath/ 开头,则原样返回。 -func stripRootPrefix(p, rootPath string) string { - if rootPath == "" { - return p - } - prefix := rootPath + "/" - if strings.HasPrefix(p, prefix) { - return strings.TrimPrefix(p, prefix) - } - return p -} - // Link 拼装文件的直接下载/访问链接。 // 路径中可能包含空格、中文、#、+ 等特殊字符,必须进行安全编码以生成有效 URL。 func (d *CFImgBed) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { - rootPath := strings.Trim(d.GetRootPath(), "/") - filePath := strings.Trim(file.GetPath(), "/") - - // 拼接完整路径,避免出现双斜杠 - var fullPath string - if rootPath != "" && filePath != "" { - fullPath = rootPath + "/" + filePath - } else if rootPath != "" { - fullPath = rootPath - } else { - fullPath = filePath - } - // 对路径进行安全编码,处理空格、特殊字符等可能导致链接失效的情况 - link := strings.TrimRight(d.Address, "/") + "/file/" + utils.EncodePath(fullPath) + link := d.Address + "/file/" + utils.EncodePath(file.GetPath()) return &model.Link{URL: link}, nil } -func (d *CFImgBed) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { - return nil, errs.NotImplement -} - -func (d *CFImgBed) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { - return nil, errs.NotImplement -} - -func (d *CFImgBed) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { - return nil, errs.NotImplement -} - -func (d *CFImgBed) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { - return nil, errs.NotImplement -} - -func (d *CFImgBed) Remove(ctx context.Context, obj model.Obj) error { - return errs.NotImplement -} - -func (d *CFImgBed) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { - return nil, errs.NotImplement -} - -func (d *CFImgBed) GetArchiveMeta(ctx context.Context, obj model.Obj, args model.ArchiveArgs) (model.ArchiveMeta, error) { - return nil, errs.NotImplement -} - -func (d *CFImgBed) ListArchive(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) ([]model.Obj, error) { - return nil, errs.NotImplement -} - -func (d *CFImgBed) Extract(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) (*model.Link, error) { - return nil, errs.NotImplement -} - -func (d *CFImgBed) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, args model.ArchiveDecompressArgs) ([]model.Obj, error) { - return nil, errs.NotImplement -} - -func (d *CFImgBed) GetDetails(ctx context.Context) (*model.StorageDetails, error) { - return nil, errs.NotImplement -} - // 编译时检查 CFImgBed 是否完整实现 driver.Driver 接口。 var _ driver.Driver = (*CFImgBed)(nil) diff --git a/drivers/cloudflare_imgbed/meta.go b/drivers/cloudflare_imgbed/meta.go index e8aa170b5..97f6f1dc1 100644 --- a/drivers/cloudflare_imgbed/meta.go +++ b/drivers/cloudflare_imgbed/meta.go @@ -13,17 +13,10 @@ type Addition struct { } var config = driver.Config{ - Name: "cloudflare_imgbed", - LocalSort: false, - OnlyProxy: false, - NoCache: false, - NoUpload: true, - NeedMs: false, - DefaultRoot: "/", - CheckStatus: false, - Alert: "", - NoOverwriteUpload: false, - NoLinkURL: false, + Name: "cloudflare_imgbed", + LocalSort: true, + NoUpload: true, + DefaultRoot: "/", } func init() { diff --git a/drivers/cloudflare_imgbed/types.go b/drivers/cloudflare_imgbed/types.go index d8f52d2aa..c786182a0 100644 --- a/drivers/cloudflare_imgbed/types.go +++ b/drivers/cloudflare_imgbed/types.go @@ -1,52 +1,5 @@ package cloudflare_imgbed -import ( - "fmt" - "path" - "strconv" - "time" - - "github.com/OpenListTeam/OpenList/v4/internal/model" - "github.com/OpenListTeam/OpenList/v4/pkg/utils" -) - -// File 表示从 CFImgBed 列表 API 响应中解析出的文件对象,实现 model.Obj 接口。 -type File struct { - path string // 文件相对路径,如 "example/image.jpg" - name string // 显示名称(路径最后一段),如 "image.jpg" - size int64 // 文件大小(字节) - modTime time.Time // 最后修改时间(从 Unix 毫秒时间戳转换而来) - mime string // MIME 类型,如 "image/jpeg" -} - -func (f *File) GetPath() string { return f.path } -func (f *File) GetName() string { return f.name } -func (f *File) ModTime() time.Time { return f.modTime } -func (f *File) CreateTime() time.Time { return f.modTime } -func (f *File) GetSize() int64 { return f.size } -func (f *File) IsDir() bool { return false } -func (f *File) GetID() string { return f.path } -func (f *File) GetHash() utils.HashInfo { return utils.HashInfo{} } - -// Dir 表示从 CFImgBed 列表 API 响应中解析出的目录对象,实现 model.Obj 接口。 -type Dir struct { - path string // 目录相对路径,如 "example/subfolder" - name string // 显示名称(路径最后一段),如 "subfolder" -} - -func (d *Dir) GetPath() string { return d.path } -func (d *Dir) GetName() string { return d.name } -func (d *Dir) ModTime() time.Time { return time.Time{} } -func (d *Dir) CreateTime() time.Time { return time.Time{} } -func (d *Dir) GetSize() int64 { return 0 } -func (d *Dir) IsDir() bool { return true } -func (d *Dir) GetID() string { return d.path } -func (d *Dir) GetHash() utils.HashInfo { return utils.HashInfo{} } - -// 编译时检查 File 和 Dir 是否完整实现 model.Obj 接口。 -var _ model.Obj = (*File)(nil) -var _ model.Obj = (*Dir)(nil) - // listPageSize 定义每次向 API 请求的最大条目数。 // 采用内部分页循环拉取,以防止单目录文件过多导致 API 响应超时或内存异常。 const listPageSize = 1000 @@ -68,79 +21,3 @@ type FileItem struct { Name string `json:"name"` Metadata map[string]interface{} `json:"metadata"` } - -// getString 从 metadata 中安全提取字符串值,按 keys 顺序依次尝试。 -// 支持 string 和 float64(JSON 数字反序列化后的默认类型)两种输入。 -func getString(m map[string]interface{}, keys ...string) string { - for _, k := range keys { - if v, ok := m[k]; ok { - switch val := v.(type) { - case string: - return val - case float64: - return strconv.FormatInt(int64(val), 10) - default: - return fmt.Sprintf("%v", val) - } - } - } - return "" -} - -// getInt64 从 metadata 中安全提取 int64 值,按 keys 顺序依次尝试。 -// 同时兼容 string、float64(JSON 数字)和 int64 三种反序列化类型, -// 确保在不同 API 版本下均能正确解析。 -func getInt64(m map[string]interface{}, keys ...string) int64 { - for _, k := range keys { - if v, ok := m[k]; ok { - switch val := v.(type) { - case string: - n, _ := strconv.ParseInt(val, 10, 64) - return n - case float64: - return int64(val) - case int64: - return val - } - } - } - return 0 -} - -// parseFile 将 API 返回的 FileItem 转换为 *File 对象。 -// 字段提取策略(兼容新旧 API 版本): -// - 文件大小:优先取 FileSizeBytes(int),回退到 File-Size(string) -// - MIME 类型:优先取 FileType,回退到 File-Mime -// - 修改时间:取 TimeStamp(同时处理 int 和 string 两种格式) -func parseFile(item FileItem) *File { - name := path.Base(item.Name) - var size int64 - var modTime time.Time - var mime string - - if item.Metadata != nil { - size = getInt64(item.Metadata, "FileSizeBytes", "File-Size") - mime = getString(item.Metadata, "FileType", "File-Mime") - ts := getInt64(item.Metadata, "TimeStamp") - if ts > 0 { - modTime = time.UnixMilli(ts) - } - } - - return &File{ - path: item.Name, - name: name, - size: size, - modTime: modTime, - mime: mime, - } -} - -// parseDir 将 API 返回的目录路径字符串转换为 *Dir 对象。 -// 显示名称取路径的最后一段(即最深层目录名)。 -func parseDir(dirPath string) *Dir { - return &Dir{ - path: dirPath, - name: path.Base(dirPath), - } -} diff --git a/drivers/cloudflare_imgbed/util.go b/drivers/cloudflare_imgbed/util.go index 40ac66d77..d95eedb2a 100644 --- a/drivers/cloudflare_imgbed/util.go +++ b/drivers/cloudflare_imgbed/util.go @@ -1,3 +1,76 @@ package cloudflare_imgbed -// do others that not defined in Driver interface +import ( + "fmt" + "path" + "strconv" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +// getString 从 metadata 中安全提取字符串值,按 keys 顺序依次尝试。 +// 支持 string 和 float64(JSON 数字反序列化后的默认类型)两种输入。 +func getString(m map[string]interface{}, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok { + switch val := v.(type) { + case string: + return val + case float64: + return strconv.FormatInt(int64(val), 10) + default: + return fmt.Sprintf("%v", val) + } + } + } + return "" +} + +// getInt64 从 metadata 中安全提取 int64 值,按 keys 顺序依次尝试。 +// 同时兼容 string、float64(JSON 数字)和 int64 三种反序列化类型, +// 确保在不同 API 版本下均能正确解析。 +func getInt64(m map[string]interface{}, keys ...string) int64 { + for _, k := range keys { + if v, ok := m[k]; ok { + switch val := v.(type) { + case string: + n, _ := strconv.ParseInt(val, 10, 64) + return n + case float64: + return int64(val) + case int64: + return val + } + } + } + return 0 +} + +// parseFile 将 API 返回的 FileItem 转换为 *File 对象。 +// 字段提取策略(兼容新旧 API 版本): +// - 文件大小:优先取 FileSizeBytes(int),回退到 File-Size(string) +// - MIME 类型:优先取 FileType,回退到 File-Mime +// - 修改时间:取 TimeStamp(同时处理 int 和 string 两种格式) +func parseFile(item FileItem) *model.Object { + name := path.Base(item.Name) + var size int64 + var modTime time.Time + // var mime string + + if item.Metadata != nil { + size = getInt64(item.Metadata, "FileSizeBytes", "File-Size") + // mime = getString(item.Metadata, "FileType", "File-Mime") + ts := getInt64(item.Metadata, "TimeStamp") + if ts > 0 { + modTime = time.UnixMilli(ts) + } + } + + return &model.Object{ + Name: name, + Size: size, + Modified: modTime, + // ID: mime, + } +} From a54f30b07ded3ba5018ca8263200e6e6d7b26c7c Mon Sep 17 00:00:00 2001 From: ZZ0YY <2111479855@qq.com> Date: Sun, 3 May 2026 18:00:45 +0800 Subject: [PATCH 08/12] feat(cloudflare_imgbed): implement upload functionality and optimize performance - Added support for standard multipart form upload with zero-copy streaming. - Implemented HuggingFace LFS direct upload for large files (>20MB). - Integrated with OpenList global rate limiter and progress tracking. - Optimized memory usage using io.MultiReader for request body construction. - Added configurable upload threads for chunked HF uploads. - Support auto mkdir dir when in upload --- drivers/cloudflare_imgbed/driver.go | 180 +++++++------ drivers/cloudflare_imgbed/meta.go | 17 +- drivers/cloudflare_imgbed/types.go | 123 ++++++++- drivers/cloudflare_imgbed/upload.go | 382 ++++++++++++++++++++++++++++ drivers/cloudflare_imgbed/util.go | 167 ++++++++---- 5 files changed, 724 insertions(+), 145 deletions(-) create mode 100644 drivers/cloudflare_imgbed/upload.go diff --git a/drivers/cloudflare_imgbed/driver.go b/drivers/cloudflare_imgbed/driver.go index 53b776384..a9921e831 100644 --- a/drivers/cloudflare_imgbed/driver.go +++ b/drivers/cloudflare_imgbed/driver.go @@ -3,14 +3,17 @@ package cloudflare_imgbed import ( "context" "fmt" - stdpath "path" + "net/http" + "path" "strings" "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/go-resty/resty/v2" + log "github.com/sirupsen/logrus" ) type CFImgBed struct { @@ -19,114 +22,145 @@ type CFImgBed struct { client *resty.Client } -func (d *CFImgBed) Config() driver.Config { - return config -} - -func (d *CFImgBed) GetAddition() driver.Additional { - return &d.Addition -} +func (d *CFImgBed) Config() driver.Config { return config } +func (d *CFImgBed) GetAddition() driver.Additional { return &d.Addition } -// Init 使用 base 包提供的工厂方法初始化 HTTP 客户端, -// 并设置 API 基础地址和鉴权请求头。 func (d *CFImgBed) Init(ctx context.Context) error { - d.Address = strings.TrimRight(d.Address, "/") - d.client = base.NewRestyClient() - d.client.SetBaseURL(d.Address). + if d.UploadThread <= 0 || d.UploadThread > 32 { + d.UploadThread = 3 + } + + d.client = base.NewRestyClient(). + SetBaseURL(strings.TrimRight(d.Address, "/")). SetHeader("Authorization", "Bearer "+d.Token). SetDebug(false) - return nil -} -func (d *CFImgBed) Drop(ctx context.Context) error { + // 连通性测试:尝试获取根目录单条数据 + _, err := d.doRequest(http.MethodGet, ListApi, func(req *resty.Request) { + req.SetQueryParams(map[string]string{ + "start": "0", + "count": "1", + "dir": "/", + }) + }, nil) + if err != nil { + return fmt.Errorf("init verification failed: %w", err) + } + log.Info("Cloudflare ImgBed driver initialized successfully") return nil } -// apiError 表示 CFImgBed API 返回的通用错误响应结构。 -type apiError struct { - Error string `json:"error"` - Message string `json:"message"` +func (d *CFImgBed) Drop(ctx context.Context) error { return nil } + +// buildReqPath 拼接存储根路径与业务请求路径,确保生成的路径符合 API 预期 +func buildReqPath(rootPath, dirPath string) string { + rootPath = strings.Trim(rootPath, "/") + dirPath = strings.Trim(dirPath, "/") + if dirPath == "" || dirPath == rootPath { + return rootPath + } + if rootPath == "" { + return dirPath + } + return rootPath + "/" + dirPath } -// List 获取指定目录下的文件和子目录列表。 -// -// 采用内部分页循环拉取,以防止单目录文件过多导致 API 响应超时或内存异常。 -// 每次请求 listPageSize 条记录,直到返回数量不足一页时退出循环, -// 最终将所有分页结果汇总后一次性返回给 OpenList。 func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { - reqPath := dir.GetPath() + rootPath := strings.Trim(d.GetRootPath(), "/") + var dirPath string + if dir != nil { + dirPath = strings.Trim(dir.GetPath(), "/") + } + reqPath := buildReqPath(rootPath, dirPath) - // 用于去重:API 在分页时每个页面都可能重复返回相同的目录列表, - // 确保同一个目录对象只被添加一次。 - dirSeen := ":" + dirSeen := make(map[string]bool) + fileSeen := make(map[string]bool) objs := make([]model.Obj, 0) - // 分页拉取循环 start := 0 for { var resp ListResponse - var errResp apiError - res, err := d.client.R(). - SetQueryParam("dir", reqPath). - SetQueryParam("start", fmt.Sprintf("%d", start)). - SetQueryParam("count", fmt.Sprintf("%d", listPageSize)). - SetResult(&resp). - SetError(&errResp). - Get("/api/manage/list") - + _, err := d.doRequest(http.MethodGet, ListApi, func(req *resty.Request) { + req.SetQueryParams(map[string]string{ + "dir": reqPath, + "start": fmt.Sprintf("%d", start), + "count": fmt.Sprintf("%d", listPageSize), + }) + }, &resp) if err != nil { return nil, err } - if res.IsError() { - if errResp.Message != "" { - return nil, fmt.Errorf("CFImgBed API error: %s", errResp.Message) - } - return nil, fmt.Errorf("CFImgBed API returned status %d", res.StatusCode()) - } - // 裁剪 API 返回路径中的挂载根前缀, - // 使 GetPath() 返回的是相对于 OpenList 挂载点的路径,而非图床的绝对路径。 for _, rawDir := range resp.Directories { - p := strings.TrimRight(rawDir, "/") - // 目录去重:分页场景下不同页面可能返回相同的目录条目 - if !strings.Contains(dirSeen, ":"+p+":") { - dirSeen += p + ":" - name := stdpath.Base(p) - objs = append(objs, &model.Object{ - Name: name, - Path: stdpath.Join(reqPath, name), - Modified: d.Modified, - IsFolder: true, - }) + cleanDir := strings.TrimRight(rawDir, "/") + p := stripRootPrefix(cleanDir, rootPath) + if !dirSeen[p] { + dirSeen[p] = true + objs = append(objs, parseDir(p)) } } for _, item := range resp.Files { - obj := parseFile(item) - obj.Path = stdpath.Join(reqPath, obj.Name) - objs = append(objs, obj) + p := stripRootPrefix(item.Name, rootPath) + if !fileSeen[p] { + fileSeen[p] = true + objs = append(objs, parseFile(FileItem{Name: p, Metadata: item.Metadata})) + } } - // 判断是否已到最后一页:当返回的文件和目录总数小于请求的每页数量时, - // 说明本页已经是最后一页,无需继续请求。 - fetched := len(resp.Files) + len(resp.Directories) - if fetched < listPageSize { + // 如果当前获取的数量少于分页大小,说明已加载完毕 + if len(resp.Files)+len(resp.Directories) < listPageSize { break } - start += listPageSize } - return objs, nil } -// Link 拼装文件的直接下载/访问链接。 -// 路径中可能包含空格、中文、#、+ 等特殊字符,必须进行安全编码以生成有效 URL。 func (d *CFImgBed) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { - // 对路径进行安全编码,处理空格、特殊字符等可能导致链接失效的情况 - link := d.Address + "/file/" + utils.EncodePath(file.GetPath()) + rootPath := strings.Trim(d.GetRootPath(), "/") + filePath := strings.Trim(file.GetPath(), "/") + + var fullPath string + if rootPath != "" && filePath != "" { + fullPath = rootPath + "/" + filePath + } else if rootPath != "" { + fullPath = rootPath + } else { + fullPath = filePath + } + + link := strings.TrimRight(d.Address, "/") + "/file/" + utils.EncodePath(fullPath) return &model.Link{URL: link}, nil } -// 编译时检查 CFImgBed 是否完整实现 driver.Driver 接口。 -var _ driver.Driver = (*CFImgBed)(nil) +// MakeDir 在图床中通常是虚拟的,此处返回虚拟目录对象以支持上传时的路径展示 +func (d *CFImgBed) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + var parentPath string + if parentDir != nil { + parentPath = parentDir.GetPath() + } + fullPath := path.Join(parentPath, dirName) + return &model.Object{ + ID: fullPath, + Path: fullPath, + Name: dirName, + IsFolder: true, + }, nil +} + +func (d *CFImgBed) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + return nil, errs.NotImplement +} +func (d *CFImgBed) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { + return nil, errs.NotImplement +} +func (d *CFImgBed) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + return nil, errs.NotImplement +} +func (d *CFImgBed) Remove(ctx context.Context, obj model.Obj) error { return errs.NotImplement } +func (d *CFImgBed) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + return nil, errs.NotImplement +} + +var _ driver.Driver = (*CFImgBed)(nil) \ No newline at end of file diff --git a/drivers/cloudflare_imgbed/meta.go b/drivers/cloudflare_imgbed/meta.go index 97f6f1dc1..b3b5317e8 100644 --- a/drivers/cloudflare_imgbed/meta.go +++ b/drivers/cloudflare_imgbed/meta.go @@ -5,22 +5,23 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/op" ) -// Addition 定义驱动在 OpenList 前端管理界面中显示的表单配置项。 type Addition struct { driver.RootPath - Address string `json:"address" type:"text" required:"true" default:"" help:"API domain, https://img.example.com"` - Token string `json:"token" type:"text" required:"true" default:"" help:"API authentication token"` + Address string `json:"address" type:"text" required:"true" help:"图床后端 API 地址,例如 https://img.example.com"` + Token string `json:"token" type:"text" required:"true" help:"身份认证 Token"` + SmallChannelName string `json:"smallChannelName" type:"text" help:"普通文件(通常<20MB)上传使用的渠道名称"` + LargeChannelName string `json:"largeChannelName" type:"text" help:"大文件上传使用的渠道名称"` + LargeChannelType string `json:"largeChannelType" type:"select" options:",huggingface" help:"大文件渠道的特殊类型(如需直传 HuggingFace,请选 huggingface)"` + UploadThread int `json:"uploadThread" type:"number" default:"3" help:"HuggingFace 分片直传时的并发线程数"` } var config = driver.Config{ Name: "cloudflare_imgbed", LocalSort: true, - NoUpload: true, + NoUpload: false, DefaultRoot: "/", } func init() { - op.RegisterDriver(func() driver.Driver { - return &CFImgBed{} - }) -} + op.RegisterDriver(func() driver.Driver { return &CFImgBed{} }) +} \ No newline at end of file diff --git a/drivers/cloudflare_imgbed/types.go b/drivers/cloudflare_imgbed/types.go index c786182a0..901704ba7 100644 --- a/drivers/cloudflare_imgbed/types.go +++ b/drivers/cloudflare_imgbed/types.go @@ -1,23 +1,124 @@ package cloudflare_imgbed -// listPageSize 定义每次向 API 请求的最大条目数。 -// 采用内部分页循环拉取,以防止单目录文件过多导致 API 响应超时或内存异常。 +import ( + "fmt" + "path" + "strconv" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + const listPageSize = 1000 -// ListResponse 表示 CFImgBed 列表 API 返回的 JSON 结构。 +// ListResponse 列表接口响应 type ListResponse struct { Files []FileItem `json:"files"` Directories []string `json:"directories"` } -// FileItem 表示列表 API 返回的单个文件条目。 -// 注意:Metadata 使用 map[string]interface{} 而非 map[string]string, -// 因为实际 API 返回的字段类型不统一: -// - TimeStamp: 可能是整数(如 1774910085474),也可能在旧版本中是字符串 -// - FileSizeBytes: 整数(如 3936071) -// - FileSize: 字符串(如 "3.75")— 仅供人类阅读的格式化大小 -// - FileType: 字符串(如 "audio/mpeg") type FileItem struct { Name string `json:"name"` - Metadata map[string]interface{} `json:"metadata"` + Metadata map[string]interface{} `json:"metadata"` // 存储文件大小、哈希、时间戳等 +} + +type apiError struct { + Error string `json:"error"` + Message string `json:"message"` +} + +// standardUploadResp 标准上传成功返回的数组 +type standardUploadResp []struct { + Src string `json:"src"` } + +// hfGetUrlResp 获取 HF 直传授权地址的响应 +type hfGetUrlResp struct { + Success bool `json:"success"` + FullID string `json:"fullId"` + FilePath string `json:"filePath"` + ChannelName string `json:"channelName"` + Repo string `json:"repo"` + NeedsLfs bool `json:"needsLfs"` // 是否需要进行 LFS 物理上传 + AlreadyExists bool `json:"alreadyExists"` // 是否秒传成功 + Oid string `json:"oid"` // Git LFS 对象 ID (SHA256) + UploadAction *UploadAction `json:"uploadAction"` +} + +type UploadAction struct { + Href string `json:"href"` + Header map[string]string `json:"header"` +} + +type hfCommitResp struct { + Success bool `json:"success"` + Src string `json:"src"` + FileUrl string `json:"fileUrl"` + FullID string `json:"fullId"` +} + +// 辅助函数:从 map 中安全提取字符串/数值 +func getString(m map[string]interface{}, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok { + switch val := v.(type) { + case string: + return val + case float64: + return strconv.FormatInt(int64(val), 10) + default: + return fmt.Sprintf("%v", val) + } + } + } + return "" +} + +func getInt64(m map[string]interface{}, keys ...string) int64 { + for _, k := range keys { + if v, ok := m[k]; ok { + switch val := v.(type) { + case string: + n, _ := strconv.ParseInt(val, 10, 64) + return n + case float64: + return int64(val) + case int64: + return val + } + } + } + return 0 +} + +func parseFile(item FileItem) *model.Object { + name := path.Base(item.Name) + var size int64 + var modTime time.Time + + if item.Metadata != nil { + size = getInt64(item.Metadata, "FileSizeBytes", "File-Size") + ts := getInt64(item.Metadata, "TimeStamp") + if ts > 0 { + modTime = time.UnixMilli(ts) + } + } + + return &model.Object{ + ID: item.Name, + Path: item.Name, + Name: name, + Size: size, + Modified: modTime, + IsFolder: false, + } +} + +func parseDir(dirPath string) *model.Object { + return &model.Object{ + ID: dirPath, + Path: dirPath, + Name: path.Base(dirPath), + IsFolder: true, + } +} \ No newline at end of file diff --git a/drivers/cloudflare_imgbed/upload.go b/drivers/cloudflare_imgbed/upload.go new file mode 100644 index 000000000..e2ab615f7 --- /dev/null +++ b/drivers/cloudflare_imgbed/upload.go @@ -0,0 +1,382 @@ +package cloudflare_imgbed + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/stream" + "github.com/OpenListTeam/OpenList/v4/pkg/errgroup" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/avast/retry-go" + "github.com/go-resty/resty/v2" + log "github.com/sirupsen/logrus" +) + +func (d *CFImgBed) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + fileSize := file.GetSize() + // 如果文件较大且配置了 HuggingFace 渠道,走直传流程 + if fileSize >= hfDirectThreshold && d.LargeChannelType == "huggingface" { + log.WithField("size", fileSize).Info("file exceeds threshold, using HuggingFace direct upload") + return d.hfDirectUpload(ctx, dstDir, file, up) + } + // 否则走普通图床 API 上传 + return d.standardUpload(ctx, dstDir, file, up) +} + +// standardUpload 通过普通 multipart 表单上传。 +// 使用 io.MultiReader 实现虚拟拼接,避免将整个大文件读入内存构建表单。 +func (d *CFImgBed) standardUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + fileName := file.GetName() + fileSize := file.GetSize() + fileMime := file.GetMimetype() + uploadDir := getUploadDir(d, dstDir) + + channelName := d.SmallChannelName + if fileSize >= hfDirectThreshold { + channelName = d.LargeChannelName + log.WithField("size", fileSize).Warn("File exceeds threshold but non-HF channel is used.") + } + if channelName == "" { + return nil, fmt.Errorf("channel name not configured") + } + + // 1. 将参数放入 Query String + reqUrl, _ := url.Parse(strings.TrimRight(d.Address, "/") + UploadApi) + q := reqUrl.Query() + if uploadDir != "" { + q.Set("uploadFolder", uploadDir) + } + q.Set("returnFormat", "default") + q.Set("channelName", channelName) + reqUrl.RawQuery = q.Encode() + + // 2. 构建 multipart 表单的头部 + var headBuf bytes.Buffer + w := multipart.NewWriter(&headBuf) + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, escapeQuotes(fileName))) + if fileMime == "" { + fileMime = "application/octet-stream" + } + h.Set("Content-Type", fileMime) + if _, err := w.CreatePart(h); err != nil { + return nil, err + } + boundary := w.Boundary() + tailStr := fmt.Sprintf("\r\n--%s--\r\n", boundary) + + reader, err := getFileReader(file) + if err != nil { + return nil, err + } + defer reader.Close() + + progressReader := &progressReadCloser{ReadCloser: reader, total: fileSize, up: up} + + // 3. 将 [表单头 + 文件流 + 表单尾] 组合成单一 Reader + bodyStream := io.MultiReader( + bytes.NewReader(headBuf.Bytes()), + progressReader, + strings.NewReader(tailStr), + ) + + rateLimitedReader := driver.NewLimitedUploadStream(ctx, bodyStream) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqUrl.String(), rateLimitedReader) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+d.Token) + req.ContentLength = int64(headBuf.Len()) + fileSize + int64(len(tailStr)) + + res, err := base.HttpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + body, _ := io.ReadAll(res.Body) + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("upload failed %d: %s", res.StatusCode, string(body)) + } + + var resp standardUploadResp + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + if len(resp) == 0 || resp[0].Src == "" { + return nil, fmt.Errorf("no src returned") + } + + srcPath := strings.TrimPrefix(resp[0].Src, "/file/") + srcPath = strings.TrimPrefix(srcPath, "/") + displayPath := stripRootPrefix(srcPath, strings.Trim(d.GetRootPath(), "/")) + + return &model.Object{ + ID: displayPath, + Path: displayPath, + Name: fileName, + Size: fileSize, + Modified: file.ModTime(), + IsFolder: false, + }, nil +} + +// hfDirectUpload 处理 HuggingFace 的 LFS 直传逻辑(申请授权 -> 物理上传 -> 后端 Commit) +func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + fileName := file.GetName() + fileSize := file.GetSize() + fileMime := file.GetMimetype() + modTime := file.ModTime() + uploadDir := getUploadDir(d, dstDir) + + sha256Hash, fileSample, err := prepareHFUploadData(file) + if err != nil { + return nil, err + } + + channelName := d.LargeChannelName + if channelName == "" { + return nil, fmt.Errorf("LargeChannelName not configured") + } + + // 1. 请求图床后端获取 HF 授权地址 + reqBody := map[string]interface{}{ + "fileName": fileName, + "fileType": fileMime, + "fileSize": fileSize, + "sha256": sha256Hash, + "fileSample": fileSample, + "channelName": channelName, + "uploadFolder": uploadDir, + } + + var getUrlResp hfGetUrlResp + _, err = d.doRequest(http.MethodPost, HFGetUrlApi, func(req *resty.Request) { + req.SetBody(reqBody) + req.SetHeader("Content-Type", "application/json") + }, &getUrlResp) + if err != nil { + return nil, err + } + + // 秒传逻辑 + if getUrlResp.AlreadyExists || !getUrlResp.NeedsLfs { + return d.hfCommit(ctx, getUrlResp, fileName, fileSize, fileMime, modTime) + } + + if getUrlResp.UploadAction == nil { + return nil, fmt.Errorf("HF upload action is nil") + } + + headers := getUrlResp.UploadAction.Header + href := getUrlResp.UploadAction.Href + + if _, err := file.GetFile().Seek(0, io.SeekStart); err != nil { + return nil, err + } + + // 2. 根据响应判断是执行分片上传还是单文件上传 + chunkSizeStr, needChunk := headers["chunk_size"] + if needChunk { + // 分片直传 (AWS S3 Multipart 风格) + chunkSize, _ := strconv.ParseInt(chunkSizeStr, 10, 64) + if chunkSize <= 0 { + chunkSize = 20 * 1024 * 1024 + } + + partUrls := make(map[int]string) + for k, v := range headers { + if len(k) == 5 { // 格式通常为 "00001", "00002" + if idx, err := strconv.Atoi(k); err == nil { + partUrls[idx] = v + } + } + } + totalParts := len(partUrls) + + ss, err := stream.NewStreamSectionReader(file, int(chunkSize), nil) + if err != nil { + return nil, err + } + + g, uploadCtx := errgroup.NewOrderedGroupWithContext(ctx, d.UploadThread, + retry.Attempts(3), + retry.Delay(time.Second), + retry.DelayType(retry.BackOffDelay)) + + var partsMutex sync.Mutex + parts := make([]map[string]interface{}, 0, totalParts) + + for partNumber := 1; partNumber <= totalParts; partNumber++ { + partNumber := partNumber + partUrl := partUrls[partNumber] + offset := int64(partNumber-1) * chunkSize + sizeToRead := chunkSize + if offset+sizeToRead > fileSize { + sizeToRead = fileSize - offset + } + + g.GoWithLifecycle(errgroup.Lifecycle{ + Do: func(ctx context.Context) error { + reader, err := ss.GetSectionReader(offset, sizeToRead) + if err != nil { + return err + } + defer ss.FreeSectionReader(reader) + + limitedReader := driver.NewLimitedUploadStream(ctx, reader) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, partUrl, limitedReader) + if err != nil { + return err + } + for key, val := range headers { + if len(key) != 5 && key != "chunk_size" { + req.Header.Set(key, val) + } + } + req.ContentLength = sizeToRead + + res, err := base.HttpClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("chunk %d failed: %d", partNumber, res.StatusCode) + } + + etag := res.Header.Get("ETag") + partsMutex.Lock() + parts = append(parts, map[string]interface{}{"partNumber": partNumber, "etag": etag}) + partsMutex.Unlock() + + if up != nil { + up(100 * float64(g.Success()+1) / float64(totalParts)) + } + return nil + }, + }) + if utils.IsCanceled(uploadCtx) { + break + } + } + + if err := g.Wait(); err != nil { + return nil, err + } + + // 合并分片 + sort.Slice(parts, func(i, j int) bool { return parts[i]["partNumber"].(int) < parts[j]["partNumber"].(int) }) + mergeBody, _ := json.Marshal(map[string]interface{}{"oid": getUrlResp.Oid, "parts": parts}) + mergeReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, href, bytes.NewReader(mergeBody)) + mergeReq.Header.Set("Content-Type", "application/vnd.git-lfs+json") + for k, v := range headers { + if k != "chunk_size" && len(k) != 5 { + mergeReq.Header.Set(k, v) + } + } + res, err := base.HttpClient.Do(mergeReq) + if err != nil || res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("merge chunks failed") + } + res.Body.Close() + + } else { + // 单文件直传 (PUT) + cachedFile := file.GetFile() + cachedFile.Seek(0, io.SeekStart) + progressReader := &progressReadCloser{ReadCloser: io.NopCloser(cachedFile), total: fileSize, up: up} + + limitedReader := driver.NewLimitedUploadStream(ctx, progressReader) + req, _ := http.NewRequestWithContext(ctx, http.MethodPut, href, limitedReader) + req.ContentLength = fileSize + for k, v := range headers { + req.Header.Set(k, v) + } + res, err := base.HttpClient.Do(req) + if err != nil || res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("direct upload failed") + } + res.Body.Close() + } + + // 3. 通知图床后端完成文件登记 + return d.hfCommit(ctx, getUrlResp, fileName, fileSize, fileMime, modTime) +} + +func (d *CFImgBed) hfCommit(ctx context.Context, getUrlResp hfGetUrlResp, fileName string, fileSize int64, fileMime string, modTime time.Time) (model.Obj, error) { + commitBody := map[string]interface{}{ + "fullId": getUrlResp.FullID, + "filePath": getUrlResp.FilePath, + "sha256": getUrlResp.Oid, + "fileSize": fileSize, + "fileName": fileName, + "fileType": fileMime, + "channelName": getUrlResp.ChannelName, + } + var commitResp hfCommitResp + _, err := d.doRequest(http.MethodPost, HFCommitApi, func(req *resty.Request) { + req.SetBody(commitBody) + }, &commitResp) + if err != nil || !commitResp.Success { + return nil, fmt.Errorf("HF commit failed") + } + + srcPath := strings.TrimPrefix(commitResp.Src, "/file/") + displayPath := stripRootPrefix(strings.TrimPrefix(srcPath, "/"), strings.Trim(d.GetRootPath(), "/")) + + return &model.Object{ + ID: displayPath, + Path: displayPath, + Name: fileName, + Size: fileSize, + Modified: modTime, + IsFolder: false, + }, nil +} + +func getFileReader(file model.FileStreamer) (io.ReadCloser, error) { + if cached := file.GetFile(); cached != nil { + if _, err := cached.Seek(0, io.SeekStart); err != nil { + return nil, err + } + if rc, ok := cached.(io.ReadCloser); ok { + return rc, nil + } + return io.NopCloser(cached), nil + } + return io.NopCloser(file), nil +} + +type progressReadCloser struct { + io.ReadCloser + total int64 + read int64 + up driver.UpdateProgress +} + +func (r *progressReadCloser) Read(p []byte) (n int, err error) { + n, err = r.ReadCloser.Read(p) + r.read += int64(n) + if r.total > 0 && r.up != nil { + r.up(100 * float64(r.read) / float64(r.total)) + } + return +} \ No newline at end of file diff --git a/drivers/cloudflare_imgbed/util.go b/drivers/cloudflare_imgbed/util.go index d95eedb2a..845901273 100644 --- a/drivers/cloudflare_imgbed/util.go +++ b/drivers/cloudflare_imgbed/util.go @@ -1,76 +1,137 @@ package cloudflare_imgbed import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" "fmt" + "io" "path" - "strconv" + "strings" "time" "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/go-resty/resty/v2" + log "github.com/sirupsen/logrus" ) -// getString 从 metadata 中安全提取字符串值,按 keys 顺序依次尝试。 -// 支持 string 和 float64(JSON 数字反序列化后的默认类型)两种输入。 -func getString(m map[string]interface{}, keys ...string) string { - for _, k := range keys { - if v, ok := m[k]; ok { - switch val := v.(type) { - case string: - return val - case float64: - return strconv.FormatInt(int64(val), 10) - default: - return fmt.Sprintf("%v", val) +const ( + ListApi = "/api/manage/list" + UploadApi = "/upload" + HFGetUrlApi = "/upload/huggingface/getUploadUrl" + HFCommitApi = "/upload/huggingface/commitUpload" + hfDirectThreshold int64 = 20 * 1024 * 1024 + fileSampleSize = 512 // HF 申请上传地址时需提供文件前 512 字节的 Sample +) + +// doRequest 通用请求封装,包含重试和 API 错误解析 +func (d *CFImgBed) doRequest(method, urlPath string, callback func(*resty.Request), resp interface{}) ([]byte, error) { + maxRetries := 3 + for i := 0; i < maxRetries; i++ { + req := d.client.R() + if callback != nil { + callback(req) + } + if resp != nil { + req.SetResult(resp) + } + + res, err := req.Execute(method, urlPath) + if err != nil { + log.WithError(err).Warnf("request %s %s failed, attempt %d/%d", method, urlPath, i+1, maxRetries) + if i < maxRetries-1 { + time.Sleep(time.Duration(i+1) * time.Second) + continue } + return nil, err } - } - return "" -} -// getInt64 从 metadata 中安全提取 int64 值,按 keys 顺序依次尝试。 -// 同时兼容 string、float64(JSON 数字)和 int64 三种反序列化类型, -// 确保在不同 API 版本下均能正确解析。 -func getInt64(m map[string]interface{}, keys ...string) int64 { - for _, k := range keys { - if v, ok := m[k]; ok { - switch val := v.(type) { - case string: - n, _ := strconv.ParseInt(val, 10, 64) - return n - case float64: - return int64(val) - case int64: - return val + body := res.Body() + var apiErr apiError + if err := json.Unmarshal(body, &apiErr); err == nil { + if apiErr.Error != "" || apiErr.Message != "" { + msg := apiErr.Error + if msg == "" { + msg = apiErr.Message + } + return nil, fmt.Errorf("API error: %s", msg) } } + + if res.StatusCode() == 429 { + time.Sleep(time.Duration(i+1) * 2 * time.Second) + continue + } + + if res.IsError() { + return nil, fmt.Errorf("HTTP %d", res.StatusCode()) + } + return body, nil } - return 0 + return nil, fmt.Errorf("max retries exceeded") } -// parseFile 将 API 返回的 FileItem 转换为 *File 对象。 -// 字段提取策略(兼容新旧 API 版本): -// - 文件大小:优先取 FileSizeBytes(int),回退到 File-Size(string) -// - MIME 类型:优先取 FileType,回退到 File-Mime -// - 修改时间:取 TimeStamp(同时处理 int 和 string 两种格式) -func parseFile(item FileItem) *model.Object { - name := path.Base(item.Name) - var size int64 - var modTime time.Time - // var mime string - - if item.Metadata != nil { - size = getInt64(item.Metadata, "FileSizeBytes", "File-Size") - // mime = getString(item.Metadata, "FileType", "File-Mime") - ts := getInt64(item.Metadata, "TimeStamp") - if ts > 0 { - modTime = time.UnixMilli(ts) +// prepareHFUploadData 为 HF 直传计算 SHA256 哈希并提取头部样本数据 +func prepareHFUploadData(file model.FileStreamer) (string, string, error) { + if file.GetFile() == nil { + if _, err := file.CacheFullAndWriter(nil, nil); err != nil { + return "", "", err } } - return &model.Object{ - Name: name, - Size: size, - Modified: modTime, - // ID: mime, + cached := file.GetFile() + + // 优先从 HashInfo 获取,避免重复全量读取文件 + sha256Hex := file.GetHash().GetHash(utils.SHA256) + if len(sha256Hex) == 0 { + cached.Seek(0, io.SeekStart) + hash := sha256.New() + io.Copy(hash, cached) + sha256Hex = hex.EncodeToString(hash.Sum(nil)) + } + + // 提取前 512 字节作为样本 + cached.Seek(0, io.SeekStart) + sampleBuf := make([]byte, fileSampleSize) + n, err := io.ReadFull(cached, sampleBuf) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return "", "", err + } + sampleBase64 := base64.StdEncoding.EncodeToString(sampleBuf[:n]) + + return sha256Hex, sampleBase64, nil +} + +func getUploadDir(d *CFImgBed, dstDir model.Obj) string { + rootPath := strings.Trim(d.GetRootPath(), "/") + var dirPath string + if dstDir != nil { + dirPath = strings.Trim(dstDir.GetPath(), "/") + } + if rootPath != "" && dirPath != "" { + return path.Join(rootPath, dirPath) + } + if rootPath != "" { + return rootPath } + return dirPath } + +func stripRootPrefix(p, rootPath string) string { + if rootPath == "" { + return p + } + prefix := rootPath + "/" + if strings.HasPrefix(p, prefix) { + return strings.TrimPrefix(p, prefix) + } + return p +} + +var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") + +func escapeQuotes(s string) string { + return quoteEscaper.Replace(s) +} \ No newline at end of file From 1fdf6a32d2cecf4e82293c21b9ca3a1c6b956569 Mon Sep 17 00:00:00 2001 From: ZZ0YY <2111479855@qq.com> Date: Sun, 3 May 2026 19:29:36 +0800 Subject: [PATCH 09/12] refactor: simplify path handling logic --- drivers/cloudflare_imgbed/driver.go | 57 ++++++----------------------- drivers/cloudflare_imgbed/meta.go | 2 +- drivers/cloudflare_imgbed/types.go | 2 +- drivers/cloudflare_imgbed/upload.go | 21 +++++------ drivers/cloudflare_imgbed/util.go | 39 +++----------------- 5 files changed, 29 insertions(+), 92 deletions(-) diff --git a/drivers/cloudflare_imgbed/driver.go b/drivers/cloudflare_imgbed/driver.go index a9921e831..91a51d67a 100644 --- a/drivers/cloudflare_imgbed/driver.go +++ b/drivers/cloudflare_imgbed/driver.go @@ -22,7 +22,7 @@ type CFImgBed struct { client *resty.Client } -func (d *CFImgBed) Config() driver.Config { return config } +func (d *CFImgBed) Config() driver.Config { return config } func (d *CFImgBed) GetAddition() driver.Additional { return &d.Addition } func (d *CFImgBed) Init(ctx context.Context) error { @@ -52,26 +52,8 @@ func (d *CFImgBed) Init(ctx context.Context) error { func (d *CFImgBed) Drop(ctx context.Context) error { return nil } -// buildReqPath 拼接存储根路径与业务请求路径,确保生成的路径符合 API 预期 -func buildReqPath(rootPath, dirPath string) string { - rootPath = strings.Trim(rootPath, "/") - dirPath = strings.Trim(dirPath, "/") - if dirPath == "" || dirPath == rootPath { - return rootPath - } - if rootPath == "" { - return dirPath - } - return rootPath + "/" + dirPath -} - func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { - rootPath := strings.Trim(d.GetRootPath(), "/") - var dirPath string - if dir != nil { - dirPath = strings.Trim(dir.GetPath(), "/") - } - reqPath := buildReqPath(rootPath, dirPath) + reqPath := dir.GetPath() dirSeen := make(map[string]bool) fileSeen := make(map[string]bool) @@ -93,18 +75,16 @@ func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) for _, rawDir := range resp.Directories { cleanDir := strings.TrimRight(rawDir, "/") - p := stripRootPrefix(cleanDir, rootPath) - if !dirSeen[p] { - dirSeen[p] = true - objs = append(objs, parseDir(p)) + if !dirSeen[cleanDir] { + dirSeen[cleanDir] = true + objs = append(objs, parseDir(cleanDir)) } } for _, item := range resp.Files { - p := stripRootPrefix(item.Name, rootPath) - if !fileSeen[p] { - fileSeen[p] = true - objs = append(objs, parseFile(FileItem{Name: p, Metadata: item.Metadata})) + if !fileSeen[item.Name] { + fileSeen[item.Name] = true + objs = append(objs, parseFile(item)) } } @@ -118,29 +98,14 @@ func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) } func (d *CFImgBed) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { - rootPath := strings.Trim(d.GetRootPath(), "/") - filePath := strings.Trim(file.GetPath(), "/") - - var fullPath string - if rootPath != "" && filePath != "" { - fullPath = rootPath + "/" + filePath - } else if rootPath != "" { - fullPath = rootPath - } else { - fullPath = filePath - } - + fullPath := file.GetPath() link := strings.TrimRight(d.Address, "/") + "/file/" + utils.EncodePath(fullPath) return &model.Link{URL: link}, nil } // MakeDir 在图床中通常是虚拟的,此处返回虚拟目录对象以支持上传时的路径展示 func (d *CFImgBed) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { - var parentPath string - if parentDir != nil { - parentPath = parentDir.GetPath() - } - fullPath := path.Join(parentPath, dirName) + fullPath := path.Join(parentDir.GetPath(), dirName) return &model.Object{ ID: fullPath, Path: fullPath, @@ -163,4 +128,4 @@ func (d *CFImgBed) GetDetails(ctx context.Context) (*model.StorageDetails, error return nil, errs.NotImplement } -var _ driver.Driver = (*CFImgBed)(nil) \ No newline at end of file +var _ driver.Driver = (*CFImgBed)(nil) diff --git a/drivers/cloudflare_imgbed/meta.go b/drivers/cloudflare_imgbed/meta.go index b3b5317e8..1f81f581f 100644 --- a/drivers/cloudflare_imgbed/meta.go +++ b/drivers/cloudflare_imgbed/meta.go @@ -24,4 +24,4 @@ var config = driver.Config{ func init() { op.RegisterDriver(func() driver.Driver { return &CFImgBed{} }) -} \ No newline at end of file +} diff --git a/drivers/cloudflare_imgbed/types.go b/drivers/cloudflare_imgbed/types.go index 901704ba7..a013d116b 100644 --- a/drivers/cloudflare_imgbed/types.go +++ b/drivers/cloudflare_imgbed/types.go @@ -121,4 +121,4 @@ func parseDir(dirPath string) *model.Object { Name: path.Base(dirPath), IsFolder: true, } -} \ No newline at end of file +} diff --git a/drivers/cloudflare_imgbed/upload.go b/drivers/cloudflare_imgbed/upload.go index e2ab615f7..15976cf9c 100644 --- a/drivers/cloudflare_imgbed/upload.go +++ b/drivers/cloudflare_imgbed/upload.go @@ -44,7 +44,7 @@ func (d *CFImgBed) standardUpload(ctx context.Context, dstDir model.Obj, file mo fileName := file.GetName() fileSize := file.GetSize() fileMime := file.GetMimetype() - uploadDir := getUploadDir(d, dstDir) + uploadDir := dstDir.GetPath() channelName := d.SmallChannelName if fileSize >= hfDirectThreshold { @@ -125,11 +125,10 @@ func (d *CFImgBed) standardUpload(ctx context.Context, dstDir model.Obj, file mo srcPath := strings.TrimPrefix(resp[0].Src, "/file/") srcPath = strings.TrimPrefix(srcPath, "/") - displayPath := stripRootPrefix(srcPath, strings.Trim(d.GetRootPath(), "/")) return &model.Object{ - ID: displayPath, - Path: displayPath, + ID: srcPath, + Path: srcPath, Name: fileName, Size: fileSize, Modified: file.ModTime(), @@ -143,7 +142,7 @@ func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file mo fileSize := file.GetSize() fileMime := file.GetMimetype() modTime := file.ModTime() - uploadDir := getUploadDir(d, dstDir) + uploadDir := dstDir.GetPath() sha256Hash, fileSample, err := prepareHFUploadData(file) if err != nil { @@ -199,7 +198,7 @@ func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file mo if chunkSize <= 0 { chunkSize = 20 * 1024 * 1024 } - + partUrls := make(map[int]string) for k, v := range headers { if len(k) == 5 { // 格式通常为 "00001", "00002" @@ -303,7 +302,7 @@ func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file mo cachedFile := file.GetFile() cachedFile.Seek(0, io.SeekStart) progressReader := &progressReadCloser{ReadCloser: io.NopCloser(cachedFile), total: fileSize, up: up} - + limitedReader := driver.NewLimitedUploadStream(ctx, progressReader) req, _ := http.NewRequestWithContext(ctx, http.MethodPut, href, limitedReader) req.ContentLength = fileSize @@ -340,11 +339,11 @@ func (d *CFImgBed) hfCommit(ctx context.Context, getUrlResp hfGetUrlResp, fileNa } srcPath := strings.TrimPrefix(commitResp.Src, "/file/") - displayPath := stripRootPrefix(strings.TrimPrefix(srcPath, "/"), strings.Trim(d.GetRootPath(), "/")) + srcPath = strings.TrimPrefix(srcPath, "/") return &model.Object{ - ID: displayPath, - Path: displayPath, + ID: srcPath, + Path: srcPath, Name: fileName, Size: fileSize, Modified: modTime, @@ -379,4 +378,4 @@ func (r *progressReadCloser) Read(p []byte) (n int, err error) { r.up(100 * float64(r.read) / float64(r.total)) } return -} \ No newline at end of file +} diff --git a/drivers/cloudflare_imgbed/util.go b/drivers/cloudflare_imgbed/util.go index 845901273..fb34ac8dd 100644 --- a/drivers/cloudflare_imgbed/util.go +++ b/drivers/cloudflare_imgbed/util.go @@ -7,7 +7,6 @@ import ( "encoding/json" "fmt" "io" - "path" "strings" "time" @@ -18,12 +17,12 @@ import ( ) const ( - ListApi = "/api/manage/list" - UploadApi = "/upload" - HFGetUrlApi = "/upload/huggingface/getUploadUrl" - HFCommitApi = "/upload/huggingface/commitUpload" + ListApi = "/api/manage/list" + UploadApi = "/upload" + HFGetUrlApi = "/upload/huggingface/getUploadUrl" + HFCommitApi = "/upload/huggingface/commitUpload" hfDirectThreshold int64 = 20 * 1024 * 1024 - fileSampleSize = 512 // HF 申请上传地址时需提供文件前 512 字节的 Sample + fileSampleSize = 512 // HF 申请上传地址时需提供文件前 512 字节的 Sample ) // doRequest 通用请求封装,包含重试和 API 错误解析 @@ -104,34 +103,8 @@ func prepareHFUploadData(file model.FileStreamer) (string, string, error) { return sha256Hex, sampleBase64, nil } -func getUploadDir(d *CFImgBed, dstDir model.Obj) string { - rootPath := strings.Trim(d.GetRootPath(), "/") - var dirPath string - if dstDir != nil { - dirPath = strings.Trim(dstDir.GetPath(), "/") - } - if rootPath != "" && dirPath != "" { - return path.Join(rootPath, dirPath) - } - if rootPath != "" { - return rootPath - } - return dirPath -} - -func stripRootPrefix(p, rootPath string) string { - if rootPath == "" { - return p - } - prefix := rootPath + "/" - if strings.HasPrefix(p, prefix) { - return strings.TrimPrefix(p, prefix) - } - return p -} - var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") func escapeQuotes(s string) string { return quoteEscaper.Replace(s) -} \ No newline at end of file +} From 865c19f1a3875f9d68723a7ddf07d20c6642ff1e Mon Sep 17 00:00:00 2001 From: j2rong4cn Date: Sun, 3 May 2026 20:07:03 +0800 Subject: [PATCH 10/12] refactor(cloudflare_imgbed): streamline API endpoint constants and improve initialization logic --- drivers/cloudflare_imgbed/driver.go | 45 ++++++++++++++--------------- drivers/cloudflare_imgbed/types.go | 10 ------- drivers/cloudflare_imgbed/upload.go | 8 ++--- drivers/cloudflare_imgbed/util.go | 9 +++--- 4 files changed, 29 insertions(+), 43 deletions(-) diff --git a/drivers/cloudflare_imgbed/driver.go b/drivers/cloudflare_imgbed/driver.go index 91a51d67a..52cef4391 100644 --- a/drivers/cloudflare_imgbed/driver.go +++ b/drivers/cloudflare_imgbed/driver.go @@ -9,11 +9,9 @@ import ( "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/internal/driver" - "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/go-resty/resty/v2" - log "github.com/sirupsen/logrus" ) type CFImgBed struct { @@ -26,17 +24,19 @@ func (d *CFImgBed) Config() driver.Config { return config } func (d *CFImgBed) GetAddition() driver.Additional { return &d.Addition } func (d *CFImgBed) Init(ctx context.Context) error { - if d.UploadThread <= 0 || d.UploadThread > 32 { + d.UploadThread = min(d.UploadThread, 32) + if d.UploadThread < 1 { d.UploadThread = 3 } + d.Address = strings.TrimRight(d.Address, "/") d.client = base.NewRestyClient(). - SetBaseURL(strings.TrimRight(d.Address, "/")). + SetBaseURL(d.Address). SetHeader("Authorization", "Bearer "+d.Token). SetDebug(false) // 连通性测试:尝试获取根目录单条数据 - _, err := d.doRequest(http.MethodGet, ListApi, func(req *resty.Request) { + _, err := d.doRequest(http.MethodGet, listApi, func(req *resty.Request) { req.SetQueryParams(map[string]string{ "start": "0", "count": "1", @@ -46,7 +46,6 @@ func (d *CFImgBed) Init(ctx context.Context) error { if err != nil { return fmt.Errorf("init verification failed: %w", err) } - log.Info("Cloudflare ImgBed driver initialized successfully") return nil } @@ -62,7 +61,7 @@ func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) start := 0 for { var resp ListResponse - _, err := d.doRequest(http.MethodGet, ListApi, func(req *resty.Request) { + _, err := d.doRequest(http.MethodGet, listApi, func(req *resty.Request) { req.SetQueryParams(map[string]string{ "dir": reqPath, "start": fmt.Sprintf("%d", start), @@ -77,7 +76,12 @@ func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) cleanDir := strings.TrimRight(rawDir, "/") if !dirSeen[cleanDir] { dirSeen[cleanDir] = true - objs = append(objs, parseDir(cleanDir)) + objs = append(objs, &model.Object{ + Path: cleanDir, + Name: path.Base(cleanDir), + Modified: d.Modified, + IsFolder: true, + }) } } @@ -98,34 +102,27 @@ func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) } func (d *CFImgBed) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { - fullPath := file.GetPath() - link := strings.TrimRight(d.Address, "/") + "/file/" + utils.EncodePath(fullPath) - return &model.Link{URL: link}, nil + return &model.Link{URL: d.Address + "/file/" + utils.EncodePath(file.GetPath())}, nil } // MakeDir 在图床中通常是虚拟的,此处返回虚拟目录对象以支持上传时的路径展示 func (d *CFImgBed) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { fullPath := path.Join(parentDir.GetPath(), dirName) return &model.Object{ - ID: fullPath, Path: fullPath, Name: dirName, IsFolder: true, }, nil } -func (d *CFImgBed) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { - return nil, errs.NotImplement -} -func (d *CFImgBed) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { - return nil, errs.NotImplement -} -func (d *CFImgBed) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { - return nil, errs.NotImplement -} -func (d *CFImgBed) Remove(ctx context.Context, obj model.Obj) error { return errs.NotImplement } -func (d *CFImgBed) GetDetails(ctx context.Context) (*model.StorageDetails, error) { - return nil, errs.NotImplement +func (d *CFImgBed) Remove(ctx context.Context, obj model.Obj) error { + reqPath := obj.GetPath() + _, err := d.doRequest(http.MethodPost, deleteApi, func(req *resty.Request) { + req.SetBody(map[string]string{ + "path": reqPath, + }).SetQueryParam("folder", fmt.Sprintf("%t", obj.IsDir())) + }, nil) + return err } var _ driver.Driver = (*CFImgBed)(nil) diff --git a/drivers/cloudflare_imgbed/types.go b/drivers/cloudflare_imgbed/types.go index a013d116b..d65a1f6fa 100644 --- a/drivers/cloudflare_imgbed/types.go +++ b/drivers/cloudflare_imgbed/types.go @@ -105,7 +105,6 @@ func parseFile(item FileItem) *model.Object { } return &model.Object{ - ID: item.Name, Path: item.Name, Name: name, Size: size, @@ -113,12 +112,3 @@ func parseFile(item FileItem) *model.Object { IsFolder: false, } } - -func parseDir(dirPath string) *model.Object { - return &model.Object{ - ID: dirPath, - Path: dirPath, - Name: path.Base(dirPath), - IsFolder: true, - } -} diff --git a/drivers/cloudflare_imgbed/upload.go b/drivers/cloudflare_imgbed/upload.go index 15976cf9c..007fbe167 100644 --- a/drivers/cloudflare_imgbed/upload.go +++ b/drivers/cloudflare_imgbed/upload.go @@ -56,7 +56,7 @@ func (d *CFImgBed) standardUpload(ctx context.Context, dstDir model.Obj, file mo } // 1. 将参数放入 Query String - reqUrl, _ := url.Parse(strings.TrimRight(d.Address, "/") + UploadApi) + reqUrl, _ := url.Parse(d.Address + uploadApi) q := reqUrl.Query() if uploadDir != "" { q.Set("uploadFolder", uploadDir) @@ -127,7 +127,6 @@ func (d *CFImgBed) standardUpload(ctx context.Context, dstDir model.Obj, file mo srcPath = strings.TrimPrefix(srcPath, "/") return &model.Object{ - ID: srcPath, Path: srcPath, Name: fileName, Size: fileSize, @@ -166,7 +165,7 @@ func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file mo } var getUrlResp hfGetUrlResp - _, err = d.doRequest(http.MethodPost, HFGetUrlApi, func(req *resty.Request) { + _, err = d.doRequest(http.MethodPost, hfGetUrlApi, func(req *resty.Request) { req.SetBody(reqBody) req.SetHeader("Content-Type", "application/json") }, &getUrlResp) @@ -331,7 +330,7 @@ func (d *CFImgBed) hfCommit(ctx context.Context, getUrlResp hfGetUrlResp, fileNa "channelName": getUrlResp.ChannelName, } var commitResp hfCommitResp - _, err := d.doRequest(http.MethodPost, HFCommitApi, func(req *resty.Request) { + _, err := d.doRequest(http.MethodPost, hfCommitApi, func(req *resty.Request) { req.SetBody(commitBody) }, &commitResp) if err != nil || !commitResp.Success { @@ -342,7 +341,6 @@ func (d *CFImgBed) hfCommit(ctx context.Context, getUrlResp hfGetUrlResp, fileNa srcPath = strings.TrimPrefix(srcPath, "/") return &model.Object{ - ID: srcPath, Path: srcPath, Name: fileName, Size: fileSize, diff --git a/drivers/cloudflare_imgbed/util.go b/drivers/cloudflare_imgbed/util.go index fb34ac8dd..abdbd4976 100644 --- a/drivers/cloudflare_imgbed/util.go +++ b/drivers/cloudflare_imgbed/util.go @@ -17,10 +17,11 @@ import ( ) const ( - ListApi = "/api/manage/list" - UploadApi = "/upload" - HFGetUrlApi = "/upload/huggingface/getUploadUrl" - HFCommitApi = "/upload/huggingface/commitUpload" + listApi = "/api/manage/list" + deleteApi = "/api/manage/delete" + uploadApi = "/upload" + hfGetUrlApi = "/upload/huggingface/getUploadUrl" + hfCommitApi = "/upload/huggingface/commitUpload" hfDirectThreshold int64 = 20 * 1024 * 1024 fileSampleSize = 512 // HF 申请上传地址时需提供文件前 512 字节的 Sample ) From 7b5259b7305476a42557517b4b9d65b021560e12 Mon Sep 17 00:00:00 2001 From: j2rong4cn Date: Sun, 3 May 2026 21:33:34 +0800 Subject: [PATCH 11/12] refactor(cloudflare_imgbed): clean up upload logic and remove unused functions --- drivers/all.go | 2 +- drivers/cloudflare_imgbed/upload.go | 215 +++++++++++++--------------- drivers/cloudflare_imgbed/util.go | 44 ------ go.mod | 2 - 4 files changed, 97 insertions(+), 166 deletions(-) diff --git a/drivers/all.go b/drivers/all.go index d23e928ac..91b86d618 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -23,6 +23,7 @@ import ( _ "github.com/OpenListTeam/OpenList/v4/drivers/baidu_photo" _ "github.com/OpenListTeam/OpenList/v4/drivers/chaoxing" _ "github.com/OpenListTeam/OpenList/v4/drivers/chunk" + _ "github.com/OpenListTeam/OpenList/v4/drivers/cloudflare_imgbed" _ "github.com/OpenListTeam/OpenList/v4/drivers/cloudreve" _ "github.com/OpenListTeam/OpenList/v4/drivers/cloudreve_v4" _ "github.com/OpenListTeam/OpenList/v4/drivers/cnb_releases" @@ -82,7 +83,6 @@ import ( _ "github.com/OpenListTeam/OpenList/v4/drivers/wopan" _ "github.com/OpenListTeam/OpenList/v4/drivers/wps" _ "github.com/OpenListTeam/OpenList/v4/drivers/yandex_disk" - _ "github.com/OpenListTeam/OpenList/v4/drivers/cloudflare_imgbed" ) // All do nothing,just for import diff --git a/drivers/cloudflare_imgbed/upload.go b/drivers/cloudflare_imgbed/upload.go index 007fbe167..b860c73f5 100644 --- a/drivers/cloudflare_imgbed/upload.go +++ b/drivers/cloudflare_imgbed/upload.go @@ -3,17 +3,16 @@ package cloudflare_imgbed import ( "bytes" "context" + "encoding/base64" "encoding/json" + "errors" "fmt" "io" "mime/multipart" "net/http" - "net/textproto" "net/url" - "sort" "strconv" "strings" - "sync" "time" "github.com/OpenListTeam/OpenList/v4/drivers/base" @@ -21,6 +20,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/stream" "github.com/OpenListTeam/OpenList/v4/pkg/errgroup" + "github.com/OpenListTeam/OpenList/v4/pkg/http_range" "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/avast/retry-go" "github.com/go-resty/resty/v2" @@ -31,7 +31,7 @@ func (d *CFImgBed) Put(ctx context.Context, dstDir model.Obj, file model.FileStr fileSize := file.GetSize() // 如果文件较大且配置了 HuggingFace 渠道,走直传流程 if fileSize >= hfDirectThreshold && d.LargeChannelType == "huggingface" { - log.WithField("size", fileSize).Info("file exceeds threshold, using HuggingFace direct upload") + log.WithField("size", fileSize).Debug("file exceeds threshold, using HuggingFace direct upload") return d.hfDirectUpload(ctx, dstDir, file, up) } // 否则走普通图床 API 上传 @@ -41,15 +41,11 @@ func (d *CFImgBed) Put(ctx context.Context, dstDir model.Obj, file model.FileStr // standardUpload 通过普通 multipart 表单上传。 // 使用 io.MultiReader 实现虚拟拼接,避免将整个大文件读入内存构建表单。 func (d *CFImgBed) standardUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { - fileName := file.GetName() - fileSize := file.GetSize() - fileMime := file.GetMimetype() - uploadDir := dstDir.GetPath() channelName := d.SmallChannelName - if fileSize >= hfDirectThreshold { + if file.GetSize() >= hfDirectThreshold { channelName = d.LargeChannelName - log.WithField("size", fileSize).Warn("File exceeds threshold but non-HF channel is used.") + log.WithField("size", file.GetSize()).Warn("File exceeds threshold but non-HF channel is used.") } if channelName == "" { return nil, fmt.Errorf("channel name not configured") @@ -58,65 +54,58 @@ func (d *CFImgBed) standardUpload(ctx context.Context, dstDir model.Obj, file mo // 1. 将参数放入 Query String reqUrl, _ := url.Parse(d.Address + uploadApi) q := reqUrl.Query() - if uploadDir != "" { - q.Set("uploadFolder", uploadDir) - } + q.Set("uploadFolder", dstDir.GetPath()) q.Set("returnFormat", "default") q.Set("channelName", channelName) reqUrl.RawQuery = q.Encode() // 2. 构建 multipart 表单的头部 - var headBuf bytes.Buffer - w := multipart.NewWriter(&headBuf) - h := make(textproto.MIMEHeader) - h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, escapeQuotes(fileName))) - if fileMime == "" { - fileMime = "application/octet-stream" - } - h.Set("Content-Type", fileMime) - if _, err := w.CreatePart(h); err != nil { + b := bytes.NewBuffer(make([]byte, 0, 164+len(file.GetName()))) // 预估头部大小,避免频繁扩容 + w := multipart.NewWriter(b) + _, err := w.CreateFormFile("file", file.GetName()) + if err != nil { return nil, err } - boundary := w.Boundary() - tailStr := fmt.Sprintf("\r\n--%s--\r\n", boundary) - - reader, err := getFileReader(file) + headSize := b.Len() + err = w.Close() if err != nil { return nil, err } - defer reader.Close() - - progressReader := &progressReadCloser{ReadCloser: reader, total: fileSize, up: up} + head := bytes.NewReader(b.Bytes()[:headSize]) + tail := bytes.NewReader(b.Bytes()[headSize:]) // 3. 将 [表单头 + 文件流 + 表单尾] 组合成单一 Reader - bodyStream := io.MultiReader( - bytes.NewReader(headBuf.Bytes()), - progressReader, - strings.NewReader(tailStr), - ) - - rateLimitedReader := driver.NewLimitedUploadStream(ctx, bodyStream) + rateLimitedReader := driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{ + Reader: &driver.SimpleReaderWithSize{ + Reader: io.MultiReader(head, file, tail), + Size: int64(b.Len()) + file.GetSize(), + }, + UpdateProgress: up, + }) req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqUrl.String(), rateLimitedReader) if err != nil { return nil, err } req.Header.Set("Content-Type", w.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+d.Token) - req.ContentLength = int64(headBuf.Len()) + fileSize + int64(len(tailStr)) - + req.ContentLength = int64(b.Len()) + file.GetSize() res, err := base.HttpClient.Do(req) if err != nil { return nil, err } defer res.Body.Close() - body, _ := io.ReadAll(res.Body) + b.Reset() + _, err = b.ReadFrom(res.Body) + if err != nil { + return nil, err + } if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("upload failed %d: %s", res.StatusCode, string(body)) + return nil, fmt.Errorf("upload failed %d: %s", res.StatusCode, b.String()) } var resp standardUploadResp - if err := json.Unmarshal(body, &resp); err != nil { + if err := json.Unmarshal(b.Bytes(), &resp); err != nil { return nil, err } if len(resp) == 0 || resp[0].Src == "" { @@ -128,8 +117,8 @@ func (d *CFImgBed) standardUpload(ctx context.Context, dstDir model.Obj, file mo return &model.Object{ Path: srcPath, - Name: fileName, - Size: fileSize, + Name: file.GetName(), + Size: file.GetSize(), Modified: file.ModTime(), IsFolder: false, }, nil @@ -137,31 +126,43 @@ func (d *CFImgBed) standardUpload(ctx context.Context, dstDir model.Obj, file mo // hfDirectUpload 处理 HuggingFace 的 LFS 直传逻辑(申请授权 -> 物理上传 -> 后端 Commit) func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { - fileName := file.GetName() - fileSize := file.GetSize() - fileMime := file.GetMimetype() - modTime := file.ModTime() - uploadDir := dstDir.GetPath() + channelName := d.LargeChannelName + if channelName == "" { + return nil, errors.New("LargeChannelName not configured") + } + + sha256Hash := file.GetHash().GetHash(utils.SHA256) + if len(sha256Hash) != utils.SHA256.Width { + var err error + _, sha256Hash, err = stream.CacheFullAndHash(file, &up, utils.SHA256) + if err != nil { + return nil, err + } + } - sha256Hash, fileSample, err := prepareHFUploadData(file) + fileSize := file.GetSize() + sampleSize := min(fileSize, fileSampleSize) + sampleRd, err := file.RangeRead(http_range.Range{Start: 0, Length: sampleSize}) if err != nil { return nil, err } - - channelName := d.LargeChannelName - if channelName == "" { - return nil, fmt.Errorf("LargeChannelName not configured") + sampleBuf := make([]byte, sampleSize) + _, err = io.ReadFull(sampleRd, sampleBuf) + if err != nil && err != io.EOF { + return nil, err } + fileSample := base64.StdEncoding.EncodeToString(sampleBuf) + fileMime := file.GetMimetype() // 1. 请求图床后端获取 HF 授权地址 reqBody := map[string]interface{}{ - "fileName": fileName, + "fileName": file.GetName(), "fileType": fileMime, "fileSize": fileSize, "sha256": sha256Hash, "fileSample": fileSample, "channelName": channelName, - "uploadFolder": uploadDir, + "uploadFolder": dstDir.GetPath(), } var getUrlResp hfGetUrlResp @@ -175,7 +176,7 @@ func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file mo // 秒传逻辑 if getUrlResp.AlreadyExists || !getUrlResp.NeedsLfs { - return d.hfCommit(ctx, getUrlResp, fileName, fileSize, fileMime, modTime) + return d.hfCommit(ctx, getUrlResp, file.GetName(), fileSize, fileMime, file.ModTime()) } if getUrlResp.UploadAction == nil { @@ -185,10 +186,6 @@ func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file mo headers := getUrlResp.UploadAction.Header href := getUrlResp.UploadAction.Href - if _, err := file.GetFile().Seek(0, io.SeekStart); err != nil { - return nil, err - } - // 2. 根据响应判断是执行分片上传还是单文件上传 chunkSizeStr, needChunk := headers["chunk_size"] if needChunk { @@ -208,21 +205,22 @@ func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file mo } totalParts := len(partUrls) - ss, err := stream.NewStreamSectionReader(file, int(chunkSize), nil) + ss, err := stream.NewStreamSectionReader(file, int(chunkSize), &up) if err != nil { return nil, err } - g, uploadCtx := errgroup.NewOrderedGroupWithContext(ctx, d.UploadThread, + g, uploadCtx := errgroup.NewOrderedGroupWithContext(ctx, min(d.UploadThread, totalParts), retry.Attempts(3), retry.Delay(time.Second), retry.DelayType(retry.BackOffDelay)) - var partsMutex sync.Mutex - parts := make([]map[string]interface{}, 0, totalParts) + parts := make([]map[string]any, totalParts) - for partNumber := 1; partNumber <= totalParts; partNumber++ { - partNumber := partNumber + for partNumber := range partUrls { + if utils.IsCanceled(uploadCtx) { + break + } partUrl := partUrls[partNumber] offset := int64(partNumber-1) * chunkSize sizeToRead := chunkSize @@ -230,14 +228,20 @@ func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file mo sizeToRead = fileSize - offset } + var reader io.ReadSeeker g.GoWithLifecycle(errgroup.Lifecycle{ - Do: func(ctx context.Context) error { - reader, err := ss.GetSectionReader(offset, sizeToRead) + Before: func(ctx context.Context) (err error) { + reader, err = ss.GetSectionReader(offset, sizeToRead) + return + }, + After: func(err error) { + ss.FreeSectionReader(reader) + }, + Do: func(ctx context.Context) (err error) { + _, err = reader.Seek(0, io.SeekStart) if err != nil { return err } - defer ss.FreeSectionReader(reader) - limitedReader := driver.NewLimitedUploadStream(ctx, reader) req, err := http.NewRequestWithContext(ctx, http.MethodPut, partUrl, limitedReader) if err != nil { @@ -261,19 +265,12 @@ func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file mo } etag := res.Header.Get("ETag") - partsMutex.Lock() - parts = append(parts, map[string]interface{}{"partNumber": partNumber, "etag": etag}) - partsMutex.Unlock() + parts[partNumber-1] = map[string]any{"partNumber": partNumber, "etag": etag} - if up != nil { - up(100 * float64(g.Success()+1) / float64(totalParts)) - } + up(95 * float64(g.Success()+1) / float64(totalParts)) return nil }, }) - if utils.IsCanceled(uploadCtx) { - break - } } if err := g.Wait(); err != nil { @@ -281,8 +278,8 @@ func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file mo } // 合并分片 - sort.Slice(parts, func(i, j int) bool { return parts[i]["partNumber"].(int) < parts[j]["partNumber"].(int) }) - mergeBody, _ := json.Marshal(map[string]interface{}{"oid": getUrlResp.Oid, "parts": parts}) + // sort.Slice(parts, func(i, j int) bool { return parts[i]["partNumber"].(int) < parts[j]["partNumber"].(int) }) + mergeBody, _ := json.Marshal(map[string]any{"oid": getUrlResp.Oid, "parts": parts}) mergeReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, href, bytes.NewReader(mergeBody)) mergeReq.Header.Set("Content-Type", "application/vnd.git-lfs+json") for k, v := range headers { @@ -291,32 +288,41 @@ func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file mo } } res, err := base.HttpClient.Do(mergeReq) - if err != nil || res.StatusCode != http.StatusOK { + if err != nil { + return nil, err + } + up(97) + defer res.Body.Close() + if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("merge chunks failed") } - res.Body.Close() } else { // 单文件直传 (PUT) - cachedFile := file.GetFile() - cachedFile.Seek(0, io.SeekStart) - progressReader := &progressReadCloser{ReadCloser: io.NopCloser(cachedFile), total: fileSize, up: up} + limitedReader := driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{ + Reader: file, + UpdateProgress: model.UpdateProgressWithRange(up, 0, 97), + }) - limitedReader := driver.NewLimitedUploadStream(ctx, progressReader) req, _ := http.NewRequestWithContext(ctx, http.MethodPut, href, limitedReader) req.ContentLength = fileSize for k, v := range headers { req.Header.Set(k, v) } res, err := base.HttpClient.Do(req) - if err != nil || res.StatusCode != http.StatusOK { + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("direct upload failed") } - res.Body.Close() } + defer up(100) + // 3. 通知图床后端完成文件登记 - return d.hfCommit(ctx, getUrlResp, fileName, fileSize, fileMime, modTime) + return d.hfCommit(ctx, getUrlResp, file.GetName(), fileSize, fileMime, file.ModTime()) } func (d *CFImgBed) hfCommit(ctx context.Context, getUrlResp hfGetUrlResp, fileName string, fileSize int64, fileMime string, modTime time.Time) (model.Obj, error) { @@ -348,32 +354,3 @@ func (d *CFImgBed) hfCommit(ctx context.Context, getUrlResp hfGetUrlResp, fileNa IsFolder: false, }, nil } - -func getFileReader(file model.FileStreamer) (io.ReadCloser, error) { - if cached := file.GetFile(); cached != nil { - if _, err := cached.Seek(0, io.SeekStart); err != nil { - return nil, err - } - if rc, ok := cached.(io.ReadCloser); ok { - return rc, nil - } - return io.NopCloser(cached), nil - } - return io.NopCloser(file), nil -} - -type progressReadCloser struct { - io.ReadCloser - total int64 - read int64 - up driver.UpdateProgress -} - -func (r *progressReadCloser) Read(p []byte) (n int, err error) { - n, err = r.ReadCloser.Read(p) - r.read += int64(n) - if r.total > 0 && r.up != nil { - r.up(100 * float64(r.read) / float64(r.total)) - } - return -} diff --git a/drivers/cloudflare_imgbed/util.go b/drivers/cloudflare_imgbed/util.go index abdbd4976..7a6b50502 100644 --- a/drivers/cloudflare_imgbed/util.go +++ b/drivers/cloudflare_imgbed/util.go @@ -1,17 +1,10 @@ package cloudflare_imgbed import ( - "crypto/sha256" - "encoding/base64" - "encoding/hex" "encoding/json" "fmt" - "io" - "strings" "time" - "github.com/OpenListTeam/OpenList/v4/internal/model" - "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/go-resty/resty/v2" log "github.com/sirupsen/logrus" ) @@ -72,40 +65,3 @@ func (d *CFImgBed) doRequest(method, urlPath string, callback func(*resty.Reques } return nil, fmt.Errorf("max retries exceeded") } - -// prepareHFUploadData 为 HF 直传计算 SHA256 哈希并提取头部样本数据 -func prepareHFUploadData(file model.FileStreamer) (string, string, error) { - if file.GetFile() == nil { - if _, err := file.CacheFullAndWriter(nil, nil); err != nil { - return "", "", err - } - } - - cached := file.GetFile() - - // 优先从 HashInfo 获取,避免重复全量读取文件 - sha256Hex := file.GetHash().GetHash(utils.SHA256) - if len(sha256Hex) == 0 { - cached.Seek(0, io.SeekStart) - hash := sha256.New() - io.Copy(hash, cached) - sha256Hex = hex.EncodeToString(hash.Sum(nil)) - } - - // 提取前 512 字节作为样本 - cached.Seek(0, io.SeekStart) - sampleBuf := make([]byte, fileSampleSize) - n, err := io.ReadFull(cached, sampleBuf) - if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { - return "", "", err - } - sampleBase64 := base64.StdEncoding.EncodeToString(sampleBuf[:n]) - - return sha256Hex, sampleBase64, nil -} - -var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") - -func escapeQuotes(s string) string { - return quoteEscaper.Replace(s) -} diff --git a/go.mod b/go.mod index b787b1692..cd86a8147 100644 --- a/go.mod +++ b/go.mod @@ -313,5 +313,3 @@ replace github.com/ProtonMail/go-proton-api => github.com/henrybear327/go-proton replace github.com/cronokirby/saferith => github.com/Da3zKi7/saferith v0.33.0-fixed // replace github.com/OpenListTeam/115-sdk-go => ../../OpenListTeam/115-sdk-go - - From 85f1189a1251f6095796ce51f99b2d26e7e58ad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A3=8E=E6=9C=88=E5=90=8C=E5=A4=A9?= <115799595+ZZ0YY@users.noreply.github.com> Date: Mon, 4 May 2026 13:08:21 +0800 Subject: [PATCH 12/12] docs: update help descriptions to English in cloudflare_imgbed --- drivers/cloudflare_imgbed/meta.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/drivers/cloudflare_imgbed/meta.go b/drivers/cloudflare_imgbed/meta.go index 1f81f581f..46285ca13 100644 --- a/drivers/cloudflare_imgbed/meta.go +++ b/drivers/cloudflare_imgbed/meta.go @@ -6,13 +6,13 @@ import ( ) type Addition struct { - driver.RootPath - Address string `json:"address" type:"text" required:"true" help:"图床后端 API 地址,例如 https://img.example.com"` - Token string `json:"token" type:"text" required:"true" help:"身份认证 Token"` - SmallChannelName string `json:"smallChannelName" type:"text" help:"普通文件(通常<20MB)上传使用的渠道名称"` - LargeChannelName string `json:"largeChannelName" type:"text" help:"大文件上传使用的渠道名称"` - LargeChannelType string `json:"largeChannelType" type:"select" options:",huggingface" help:"大文件渠道的特殊类型(如需直传 HuggingFace,请选 huggingface)"` - UploadThread int `json:"uploadThread" type:"number" default:"3" help:"HuggingFace 分片直传时的并发线程数"` + driver.RootPath + Address string `json:"address" type:"text" required:"true" help:"Backend API address of the image hosting service, e.g., https://img.example.com"` + Token string `json:"token" type:"text" required:"true" help:"Authentication Token"` + SmallChannelName string `json:"smallChannelName" type:"text" help:"Channel name for regular files (typically <20MB)"` + LargeChannelName string `json:"largeChannelName" type:"text" help:"Channel name for large files"` + LargeChannelType string `json:"largeChannelType" type:"select" options:",huggingface" help:"Special type for large file channels (select 'huggingface' for direct upload to HuggingFace)"` + UploadThread int `json:"uploadThread" type:"number" default:"3" help:"Concurrent thread count for HuggingFace chunked direct upload"` } var config = driver.Config{