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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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{ From 89bd84cc2b1be2f89b1b1420fa5dd98c52dba57a Mon Sep 17 00:00:00 2001 From: j2rong4cn Date: Wed, 27 May 2026 18:42:30 +0800 Subject: [PATCH 13/20] feat(cloudflare_imgbed): add virtual directory support --- drivers/cloudflare_imgbed/driver.go | 28 ++++++++++++++-- drivers/cloudflare_imgbed/upload.go | 19 +++++++---- internal/cache/weak_cache.go | 51 +++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 internal/cache/weak_cache.go diff --git a/drivers/cloudflare_imgbed/driver.go b/drivers/cloudflare_imgbed/driver.go index 52cef4391..3cd63e226 100644 --- a/drivers/cloudflare_imgbed/driver.go +++ b/drivers/cloudflare_imgbed/driver.go @@ -8,7 +8,9 @@ import ( "strings" "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/cache" "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" @@ -17,7 +19,8 @@ import ( type CFImgBed struct { model.Storage Addition - client *resty.Client + client *resty.Client + virtualDir cache.WeakCacheMap[string, model.Object] } func (d *CFImgBed) Config() driver.Config { return config } @@ -52,6 +55,10 @@ func (d *CFImgBed) Init(ctx context.Context) error { func (d *CFImgBed) Drop(ctx context.Context) error { return nil } func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + if model.ObjHasMask(dir, model.Virtual) { + return nil, nil + } + reqPath := dir.GetPath() dirSeen := make(map[string]bool) @@ -105,18 +112,33 @@ func (d *CFImgBed) Link(ctx context.Context, file model.Obj, args model.LinkArgs return &model.Link{URL: d.Address + "/file/" + utils.EncodePath(file.GetPath())}, nil } +func (d *CFImgBed) Get(ctx context.Context, pathStr string) (model.Obj, error) { + fullPath := path.Join(d.RootFolderPath, pathStr) + if obj, found := d.virtualDir.Load(fullPath); found { + return obj, nil + } + return nil, errs.NotSupport +} + // 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{ + temp := &model.Object{ Path: fullPath, Name: dirName, IsFolder: true, - }, nil + Mask: model.Virtual | model.NoMove | model.NoCopy, + } + d.virtualDir.Store(fullPath, temp) + return temp, nil } func (d *CFImgBed) Remove(ctx context.Context, obj model.Obj) error { reqPath := obj.GetPath() + if model.ObjHasMask(obj, model.Virtual) { + d.virtualDir.Delete(reqPath) + return nil + } _, err := d.doRequest(http.MethodPost, deleteApi, func(req *resty.Request) { req.SetBody(map[string]string{ "path": reqPath, diff --git a/drivers/cloudflare_imgbed/upload.go b/drivers/cloudflare_imgbed/upload.go index b860c73f5..7d3c08200 100644 --- a/drivers/cloudflare_imgbed/upload.go +++ b/drivers/cloudflare_imgbed/upload.go @@ -27,15 +27,20 @@ import ( 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() +func (d *CFImgBed) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (newObj model.Obj, err error) { + // 如果文件较大且配置了 HuggingFace 渠道,走直传流程 - if fileSize >= hfDirectThreshold && d.LargeChannelType == "huggingface" { - log.WithField("size", fileSize).Debug("file exceeds threshold, using HuggingFace direct upload") - return d.hfDirectUpload(ctx, dstDir, file, up) + if file.GetSize() >= hfDirectThreshold && d.LargeChannelType == "huggingface" { + log.WithField("size", file.GetSize()).Debug("file exceeds threshold, using HuggingFace direct upload") + newObj, err = d.hfDirectUpload(ctx, dstDir, file, up) + } else { + // 否则走普通图床 API 上传 + newObj, err = d.standardUpload(ctx, dstDir, file, up) + } + if newObj != nil && model.ObjHasMask(dstDir, model.Virtual) { + d.virtualDir.Delete(dstDir.GetPath()) } - // 否则走普通图床 API 上传 - return d.standardUpload(ctx, dstDir, file, up) + return } // standardUpload 通过普通 multipart 表单上传。 diff --git a/internal/cache/weak_cache.go b/internal/cache/weak_cache.go new file mode 100644 index 000000000..acf8682e7 --- /dev/null +++ b/internal/cache/weak_cache.go @@ -0,0 +1,51 @@ +package cache + +import ( + "runtime" + "sync" + "weak" +) + +// WeakCacheMap is a map that holds weak references to values. +// Use for shared expensive objects and automatic cleanup when no longer used. +// This object can be GC and no goroutine is used for cleanup. +type WeakCacheMap[K comparable, V any] struct { + mu sync.Mutex + m map[K]weak.Pointer[V] +} + +func NewWeakCacheMap[K comparable, V any]() *WeakCacheMap[K, V] { + return &WeakCacheMap[K, V]{ + m: make(map[K]weak.Pointer[V]), + } +} + +func (c *WeakCacheMap[K, V]) Load(key K) (value *V, ok bool) { + c.mu.Lock() + defer c.mu.Unlock() + weakPtr := c.m[key].Value() + if weakPtr != nil { + return weakPtr, true + } + return nil, false +} + +func (c *WeakCacheMap[K, V]) Store(key K, value *V) { + c.mu.Lock() + defer c.mu.Unlock() + weakPtr := weak.Make(value) + c.m[key] = weakPtr + runtime.AddCleanup(value, func(struct{}) { + c.mu.Lock() + defer c.mu.Unlock() + if c.m[key] == weakPtr { + delete(c.m, key) + } + }, struct{}{}) +} + +func (c *WeakCacheMap[K, V]) Delete(key K) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.m, key) +} From fd59e295845d214e6c795ae1b104d87e645ac232 Mon Sep 17 00:00:00 2001 From: j2rong4cn Date: Thu, 28 May 2026 15:38:02 +0800 Subject: [PATCH 14/20] refactor(weak_cache): iImprove weak pointer cleanup handling Store a cleanup handle alongside weak pointers and stop previous cleanups when overwriting entries to avoid stale removals and leaked cleanup handlers. Ensure Delete signals success and stops associated cleanup. Add Clear to stop all active cleanups. Use entry identity in the cleanup callback to avoid removing newly inserted values. --- internal/cache/weak_cache.go | 49 ++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/internal/cache/weak_cache.go b/internal/cache/weak_cache.go index acf8682e7..03b95d24f 100644 --- a/internal/cache/weak_cache.go +++ b/internal/cache/weak_cache.go @@ -11,41 +11,68 @@ import ( // This object can be GC and no goroutine is used for cleanup. type WeakCacheMap[K comparable, V any] struct { mu sync.Mutex - m map[K]weak.Pointer[V] + m map[K]*weakCacheEntry[V] +} + +type weakCacheEntry[V any] struct { + weakPtr weak.Pointer[V] + cleanup runtime.Cleanup } func NewWeakCacheMap[K comparable, V any]() *WeakCacheMap[K, V] { return &WeakCacheMap[K, V]{ - m: make(map[K]weak.Pointer[V]), + m: make(map[K]*weakCacheEntry[V]), } } func (c *WeakCacheMap[K, V]) Load(key K) (value *V, ok bool) { c.mu.Lock() defer c.mu.Unlock() - weakPtr := c.m[key].Value() - if weakPtr != nil { - return weakPtr, true + entry, exists := c.m[key] + if !exists { + return nil, false } - return nil, false + value = entry.weakPtr.Value() + return value, value != nil } func (c *WeakCacheMap[K, V]) Store(key K, value *V) { c.mu.Lock() defer c.mu.Unlock() - weakPtr := weak.Make(value) - c.m[key] = weakPtr - runtime.AddCleanup(value, func(struct{}) { + entry, exists := c.m[key] + if exists { + entry.cleanup.Stop() + } else { + entry = &weakCacheEntry[V]{} + c.m[key] = entry + } + entry.weakPtr = weak.Make(value) + entry.cleanup = runtime.AddCleanup(value, func(struct{}) { c.mu.Lock() defer c.mu.Unlock() - if c.m[key] == weakPtr { + if c.m[key] == entry { delete(c.m, key) } }, struct{}{}) } -func (c *WeakCacheMap[K, V]) Delete(key K) { +func (c *WeakCacheMap[K, V]) Delete(key K) bool { c.mu.Lock() defer c.mu.Unlock() + entry, exists := c.m[key] + if !exists { + return false + } + entry.cleanup.Stop() delete(c.m, key) + return true +} + +func (c *WeakCacheMap[K, V]) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + for _, entry := range c.m { + entry.cleanup.Stop() + } + clear(c.m) } From 50dd55da6f3026ad702baf808f2dbc6e0126af65 Mon Sep 17 00:00:00 2001 From: j2rong4cn Date: Thu, 28 May 2026 15:40:46 +0800 Subject: [PATCH 15/20] fix bug --- drivers/cloudflare_imgbed/driver.go | 26 +++++++++++++++----------- drivers/cloudflare_imgbed/meta.go | 14 +++++++------- drivers/cloudflare_imgbed/upload.go | 6 +++++- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/drivers/cloudflare_imgbed/driver.go b/drivers/cloudflare_imgbed/driver.go index 3cd63e226..983229c2b 100644 --- a/drivers/cloudflare_imgbed/driver.go +++ b/drivers/cloudflare_imgbed/driver.go @@ -20,7 +20,7 @@ type CFImgBed struct { model.Storage Addition client *resty.Client - virtualDir cache.WeakCacheMap[string, model.Object] + virtualDir *cache.WeakCacheMap[string, model.Object] } func (d *CFImgBed) Config() driver.Config { return config } @@ -49,18 +49,22 @@ func (d *CFImgBed) Init(ctx context.Context) error { if err != nil { return fmt.Errorf("init verification failed: %w", err) } + d.virtualDir = cache.NewWeakCacheMap[string, model.Object]() return nil } -func (d *CFImgBed) Drop(ctx context.Context) error { return nil } +func (d *CFImgBed) Drop(ctx context.Context) error { + d.virtualDir.Clear() + return nil +} func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { if model.ObjHasMask(dir, model.Virtual) { - return nil, nil + if _, ok := d.virtualDir.Load(dir.GetPath()); ok { + return nil, nil + } } - reqPath := dir.GetPath() - dirSeen := make(map[string]bool) fileSeen := make(map[string]bool) objs := make([]model.Obj, 0) @@ -70,7 +74,7 @@ func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) var resp ListResponse _, err := d.doRequest(http.MethodGet, listApi, func(req *resty.Request) { req.SetQueryParams(map[string]string{ - "dir": reqPath, + "dir": dir.GetPath(), "start": fmt.Sprintf("%d", start), "count": fmt.Sprintf("%d", listPageSize), }) @@ -80,12 +84,12 @@ func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) } for _, rawDir := range resp.Directories { - cleanDir := strings.TrimRight(rawDir, "/") - if !dirSeen[cleanDir] { - dirSeen[cleanDir] = true + rawDir = "/" + strings.TrimRight(rawDir, "/") + if !dirSeen[rawDir] { + dirSeen[rawDir] = true objs = append(objs, &model.Object{ - Path: cleanDir, - Name: path.Base(cleanDir), + Path: rawDir, + Name: path.Base(rawDir), Modified: d.Modified, IsFolder: true, }) diff --git a/drivers/cloudflare_imgbed/meta.go b/drivers/cloudflare_imgbed/meta.go index 46285ca13..066dfe075 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:"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"` + driver.RootPath + Address string `json:"address" required:"true" help:"Backend API address of the image hosting service, e.g., https://img.example.com"` + Token string `json:"token" required:"true" help:"Authentication Token"` + SmallChannelName string `json:"smallChannelName" help:"Channel name for regular files (typically <20MB)"` + LargeChannelName string `json:"largeChannelName" 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{ diff --git a/drivers/cloudflare_imgbed/upload.go b/drivers/cloudflare_imgbed/upload.go index 7d3c08200..2fa9250f4 100644 --- a/drivers/cloudflare_imgbed/upload.go +++ b/drivers/cloudflare_imgbed/upload.go @@ -11,6 +11,7 @@ import ( "mime/multipart" "net/http" "net/url" + "path" "strconv" "strings" "time" @@ -38,7 +39,10 @@ func (d *CFImgBed) Put(ctx context.Context, dstDir model.Obj, file model.FileStr newObj, err = d.standardUpload(ctx, dstDir, file, up) } if newObj != nil && model.ObjHasMask(dstDir, model.Virtual) { - d.virtualDir.Delete(dstDir.GetPath()) + key := dstDir.GetPath() + for d.virtualDir.Delete(key) { + key = path.Dir(key) + } } return } From 488454a9c980c89957e2c805cad4c06369e2972a Mon Sep 17 00:00:00 2001 From: j2rong4cn <36783515+j2rong4cn@users.noreply.github.com> Date: Thu, 28 May 2026 15:57:26 +0800 Subject: [PATCH 16/20] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: j2rong4cn <36783515+j2rong4cn@users.noreply.github.com> --- drivers/cloudflare_imgbed/driver.go | 4 +++- drivers/cloudflare_imgbed/upload.go | 26 +++++++++++++++++++------- drivers/cloudflare_imgbed/util.go | 13 ++++++++----- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/drivers/cloudflare_imgbed/driver.go b/drivers/cloudflare_imgbed/driver.go index 983229c2b..4d32fa2c2 100644 --- a/drivers/cloudflare_imgbed/driver.go +++ b/drivers/cloudflare_imgbed/driver.go @@ -54,7 +54,9 @@ func (d *CFImgBed) Init(ctx context.Context) error { } func (d *CFImgBed) Drop(ctx context.Context) error { - d.virtualDir.Clear() + if d.virtualDir != nil { + d.virtualDir.Clear() + } return nil } diff --git a/drivers/cloudflare_imgbed/upload.go b/drivers/cloudflare_imgbed/upload.go index 2fa9250f4..aef734c93 100644 --- a/drivers/cloudflare_imgbed/upload.go +++ b/drivers/cloudflare_imgbed/upload.go @@ -61,9 +61,11 @@ func (d *CFImgBed) standardUpload(ctx context.Context, dstDir model.Obj, file mo } // 1. 将参数放入 Query String - reqUrl, _ := url.Parse(d.Address + uploadApi) + reqUrl, err := url.Parse(d.Address + uploadApi) + if err != nil { + return nil, err + } q := reqUrl.Query() - q.Set("uploadFolder", dstDir.GetPath()) q.Set("returnFormat", "default") q.Set("channelName", channelName) reqUrl.RawQuery = q.Encode() @@ -157,7 +159,7 @@ func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file mo } sampleBuf := make([]byte, sampleSize) _, err = io.ReadFull(sampleRd, sampleBuf) - if err != nil && err != io.EOF { + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { return nil, err } fileSample := base64.StdEncoding.EncodeToString(sampleBuf) @@ -289,13 +291,17 @@ 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]any{"oid": getUrlResp.Oid, "parts": parts}) - mergeReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, href, bytes.NewReader(mergeBody)) + mergeReq, err := http.NewRequestWithContext(ctx, http.MethodPost, href, bytes.NewReader(mergeBody)) + if err != nil { + return nil, err + } 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 { return nil, err @@ -313,7 +319,10 @@ func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file mo UpdateProgress: model.UpdateProgressWithRange(up, 0, 97), }) - req, _ := http.NewRequestWithContext(ctx, http.MethodPut, href, limitedReader) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, href, limitedReader) + if err != nil { + return nil, err + } req.ContentLength = fileSize for k, v := range headers { req.Header.Set(k, v) @@ -348,8 +357,11 @@ func (d *CFImgBed) hfCommit(ctx context.Context, getUrlResp hfGetUrlResp, fileNa _, 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") + if err != nil { + return nil, fmt.Errorf("HF commit request failed: %w", err) + } + if !commitResp.Success { + return nil, fmt.Errorf("HF commit failed: success=false") } srcPath := strings.TrimPrefix(commitResp.Src, "/file/") diff --git a/drivers/cloudflare_imgbed/util.go b/drivers/cloudflare_imgbed/util.go index 7a6b50502..e974ebe37 100644 --- a/drivers/cloudflare_imgbed/util.go +++ b/drivers/cloudflare_imgbed/util.go @@ -42,6 +42,13 @@ func (d *CFImgBed) doRequest(method, urlPath string, callback func(*resty.Reques } body := res.Body() + + // Retry on rate limit before attempting to interpret the body as an API error. + if res.StatusCode() == 429 { + time.Sleep(time.Duration(i+1) * 2 * time.Second) + continue + } + var apiErr apiError if err := json.Unmarshal(body, &apiErr); err == nil { if apiErr.Error != "" || apiErr.Message != "" { @@ -53,15 +60,11 @@ func (d *CFImgBed) doRequest(method, urlPath string, callback func(*resty.Reques } } - 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 body, nil } return nil, fmt.Errorf("max retries exceeded") } From e182152718714b27633f4ce380b11a510ecff4e6 Mon Sep 17 00:00:00 2001 From: j2rong4cn Date: Thu, 28 May 2026 16:18:04 +0800 Subject: [PATCH 17/20] Adds uploadFolder param and streams base64 sample Adds an uploadFolder query parameter to forward the destination path to the backend. Replaces the fixed-size sample read with a streaming base64 encoder to avoid truncation and reduce allocations Co-authored-by: Copilot --- drivers/cloudflare_imgbed/upload.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/drivers/cloudflare_imgbed/upload.go b/drivers/cloudflare_imgbed/upload.go index aef734c93..f5034a8f1 100644 --- a/drivers/cloudflare_imgbed/upload.go +++ b/drivers/cloudflare_imgbed/upload.go @@ -68,12 +68,13 @@ func (d *CFImgBed) standardUpload(ctx context.Context, dstDir model.Obj, file mo q := reqUrl.Query() q.Set("returnFormat", "default") q.Set("channelName", channelName) + q.Set("uploadFolder", dstDir.GetPath()) reqUrl.RawQuery = q.Encode() // 2. 构建 multipart 表单的头部 b := bytes.NewBuffer(make([]byte, 0, 164+len(file.GetName()))) // 预估头部大小,避免频繁扩容 w := multipart.NewWriter(b) - _, err := w.CreateFormFile("file", file.GetName()) + _, err = w.CreateFormFile("file", file.GetName()) if err != nil { return nil, err } @@ -157,12 +158,17 @@ func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file mo if err != nil { return nil, err } - sampleBuf := make([]byte, sampleSize) - _, err = io.ReadFull(sampleRd, sampleBuf) - if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + var sampleBuilder strings.Builder + sampleEncoder := base64.NewEncoder(base64.StdEncoding, &sampleBuilder) + _, err = utils.CopyWithBuffer(sampleEncoder, sampleRd) + if err != nil { + _ = sampleEncoder.Close() + return nil, err + } + if err = sampleEncoder.Close(); err != nil { return nil, err } - fileSample := base64.StdEncoding.EncodeToString(sampleBuf) + fileSample := sampleBuilder.String() fileMime := file.GetMimetype() // 1. 请求图床后端获取 HF 授权地址 @@ -301,7 +307,7 @@ func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file mo mergeReq.Header.Set(k, v) } } - } + res, err := base.HttpClient.Do(mergeReq) if err != nil { return nil, err From 80138019f1685922ea541d8a22466062663a1644 Mon Sep 17 00:00:00 2001 From: j2rong4cn Date: Thu, 28 May 2026 16:20:33 +0800 Subject: [PATCH 18/20] refactor: add context parameter to doRequest and related calls --- drivers/cloudflare_imgbed/driver.go | 6 +++--- drivers/cloudflare_imgbed/upload.go | 4 ++-- drivers/cloudflare_imgbed/util.go | 4 +++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/drivers/cloudflare_imgbed/driver.go b/drivers/cloudflare_imgbed/driver.go index 4d32fa2c2..1404b8bcc 100644 --- a/drivers/cloudflare_imgbed/driver.go +++ b/drivers/cloudflare_imgbed/driver.go @@ -39,7 +39,7 @@ func (d *CFImgBed) Init(ctx context.Context) error { SetDebug(false) // 连通性测试:尝试获取根目录单条数据 - _, err := d.doRequest(http.MethodGet, listApi, func(req *resty.Request) { + _, err := d.doRequest(ctx, http.MethodGet, listApi, func(req *resty.Request) { req.SetQueryParams(map[string]string{ "start": "0", "count": "1", @@ -74,7 +74,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(ctx, http.MethodGet, listApi, func(req *resty.Request) { req.SetQueryParams(map[string]string{ "dir": dir.GetPath(), "start": fmt.Sprintf("%d", start), @@ -145,7 +145,7 @@ func (d *CFImgBed) Remove(ctx context.Context, obj model.Obj) error { d.virtualDir.Delete(reqPath) return nil } - _, err := d.doRequest(http.MethodPost, deleteApi, func(req *resty.Request) { + _, err := d.doRequest(ctx, http.MethodPost, deleteApi, func(req *resty.Request) { req.SetBody(map[string]string{ "path": reqPath, }).SetQueryParam("folder", fmt.Sprintf("%t", obj.IsDir())) diff --git a/drivers/cloudflare_imgbed/upload.go b/drivers/cloudflare_imgbed/upload.go index f5034a8f1..759467155 100644 --- a/drivers/cloudflare_imgbed/upload.go +++ b/drivers/cloudflare_imgbed/upload.go @@ -183,7 +183,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(ctx, http.MethodPost, hfGetUrlApi, func(req *resty.Request) { req.SetBody(reqBody) req.SetHeader("Content-Type", "application/json") }, &getUrlResp) @@ -360,7 +360,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(ctx, http.MethodPost, hfCommitApi, func(req *resty.Request) { req.SetBody(commitBody) }, &commitResp) if err != nil { diff --git a/drivers/cloudflare_imgbed/util.go b/drivers/cloudflare_imgbed/util.go index e974ebe37..6810a56bb 100644 --- a/drivers/cloudflare_imgbed/util.go +++ b/drivers/cloudflare_imgbed/util.go @@ -1,6 +1,7 @@ package cloudflare_imgbed import ( + "context" "encoding/json" "fmt" "time" @@ -20,10 +21,11 @@ const ( ) // doRequest 通用请求封装,包含重试和 API 错误解析 -func (d *CFImgBed) doRequest(method, urlPath string, callback func(*resty.Request), resp interface{}) ([]byte, error) { +func (d *CFImgBed) doRequest(ctx context.Context, method, urlPath string, callback func(*resty.Request), resp interface{}) ([]byte, error) { maxRetries := 3 for i := 0; i < maxRetries; i++ { req := d.client.R() + req.SetContext(ctx) if callback != nil { callback(req) } From a73075aba2a6799aff24f4f4a2a68ac82933691b Mon Sep 17 00:00:00 2001 From: j2rong4cn Date: Thu, 28 May 2026 16:32:14 +0800 Subject: [PATCH 19/20] fix: update List method to check args.Refresh before virtual directory mask --- drivers/cloudflare_imgbed/driver.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/cloudflare_imgbed/driver.go b/drivers/cloudflare_imgbed/driver.go index 1404b8bcc..d26dea4c7 100644 --- a/drivers/cloudflare_imgbed/driver.go +++ b/drivers/cloudflare_imgbed/driver.go @@ -61,7 +61,7 @@ func (d *CFImgBed) Drop(ctx context.Context) error { } func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { - if model.ObjHasMask(dir, model.Virtual) { + if !args.Refresh && model.ObjHasMask(dir, model.Virtual) { if _, ok := d.virtualDir.Load(dir.GetPath()); ok { return nil, nil } From b1b115733770c4accbc2d7543c9906710b377108 Mon Sep 17 00:00:00 2001 From: j2rong4cn Date: Thu, 28 May 2026 19:20:23 +0800 Subject: [PATCH 20/20] refactor: optimize List method and improve error handling in doRequest --- drivers/cloudflare_imgbed/driver.go | 36 ++++++++++++++++++----------- drivers/cloudflare_imgbed/types.go | 3 ++- drivers/cloudflare_imgbed/upload.go | 13 ++++------- drivers/cloudflare_imgbed/util.go | 4 +--- 4 files changed, 29 insertions(+), 27 deletions(-) diff --git a/drivers/cloudflare_imgbed/driver.go b/drivers/cloudflare_imgbed/driver.go index d26dea4c7..abf4813c5 100644 --- a/drivers/cloudflare_imgbed/driver.go +++ b/drivers/cloudflare_imgbed/driver.go @@ -61,15 +61,15 @@ func (d *CFImgBed) Drop(ctx context.Context) error { } func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { - if !args.Refresh && model.ObjHasMask(dir, model.Virtual) { - if _, ok := d.virtualDir.Load(dir.GetPath()); ok { - return nil, nil - } - } + // if !args.Refresh && model.ObjHasMask(dir, model.Virtual) { + // if _, ok := d.virtualDir.Load(dir.GetPath()); ok { + // return nil, nil + // } + // } - dirSeen := make(map[string]bool) - fileSeen := make(map[string]bool) - objs := make([]model.Obj, 0) + var dirSeen map[string]bool + var fileSeen map[string]bool + var objs []model.Obj start := 0 for { @@ -84,6 +84,15 @@ func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) if err != nil { return nil, err } + if len(resp.Files) == 0 && len(resp.Directories) == 0 { + break + } + + if start == 0 { + dirSeen = make(map[string]bool, len(resp.Directories)) + fileSeen = make(map[string]bool, len(resp.Files)) + objs = make([]model.Obj, 0, len(resp.Directories)+len(resp.Files)) + } for _, rawDir := range resp.Directories { rawDir = "/" + strings.TrimRight(rawDir, "/") @@ -115,7 +124,7 @@ 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) { - return &model.Link{URL: d.Address + "/file/" + utils.EncodePath(file.GetPath())}, nil + return &model.Link{URL: d.Address + "/file" + utils.EncodePath(file.GetPath())}, nil } func (d *CFImgBed) Get(ctx context.Context, pathStr string) (model.Obj, error) { @@ -133,7 +142,8 @@ func (d *CFImgBed) MakeDir(ctx context.Context, parentDir model.Obj, dirName str Path: fullPath, Name: dirName, IsFolder: true, - Mask: model.Virtual | model.NoMove | model.NoCopy, + Modified: d.Modified, + Mask: model.Virtual, } d.virtualDir.Store(fullPath, temp) return temp, nil @@ -145,10 +155,8 @@ func (d *CFImgBed) Remove(ctx context.Context, obj model.Obj) error { d.virtualDir.Delete(reqPath) return nil } - _, err := d.doRequest(ctx, http.MethodPost, deleteApi, func(req *resty.Request) { - req.SetBody(map[string]string{ - "path": reqPath, - }).SetQueryParam("folder", fmt.Sprintf("%t", obj.IsDir())) + _, err := d.doRequest(ctx, http.MethodPost, deleteApi+utils.EncodePath(reqPath), func(req *resty.Request) { + req.SetQueryParam("folder", fmt.Sprintf("%t", obj.IsDir())) }, nil) return err } diff --git a/drivers/cloudflare_imgbed/types.go b/drivers/cloudflare_imgbed/types.go index d65a1f6fa..bbacc4cfd 100644 --- a/drivers/cloudflare_imgbed/types.go +++ b/drivers/cloudflare_imgbed/types.go @@ -4,6 +4,7 @@ import ( "fmt" "path" "strconv" + "strings" "time" "github.com/OpenListTeam/OpenList/v4/internal/model" @@ -105,7 +106,7 @@ func parseFile(item FileItem) *model.Object { } return &model.Object{ - Path: item.Name, + Path: "/" + strings.TrimRight(item.Name, "/"), Name: name, Size: size, Modified: modTime, diff --git a/drivers/cloudflare_imgbed/upload.go b/drivers/cloudflare_imgbed/upload.go index 759467155..45b39f799 100644 --- a/drivers/cloudflare_imgbed/upload.go +++ b/drivers/cloudflare_imgbed/upload.go @@ -158,17 +158,12 @@ func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file mo if err != nil { return nil, err } - var sampleBuilder strings.Builder - sampleEncoder := base64.NewEncoder(base64.StdEncoding, &sampleBuilder) - _, err = utils.CopyWithBuffer(sampleEncoder, sampleRd) - if err != nil { - _ = sampleEncoder.Close() - return nil, err - } - if err = sampleEncoder.Close(); err != nil { + sampleBuf := make([]byte, sampleSize) + _, err = io.ReadFull(sampleRd, sampleBuf) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { return nil, err } - fileSample := sampleBuilder.String() + fileSample := base64.StdEncoding.EncodeToString(sampleBuf) fileMime := file.GetMimetype() // 1. 请求图床后端获取 HF 授权地址 diff --git a/drivers/cloudflare_imgbed/util.go b/drivers/cloudflare_imgbed/util.go index 6810a56bb..f9eae974c 100644 --- a/drivers/cloudflare_imgbed/util.go +++ b/drivers/cloudflare_imgbed/util.go @@ -43,14 +43,13 @@ func (d *CFImgBed) doRequest(ctx context.Context, method, urlPath string, callba return nil, err } - body := res.Body() - // Retry on rate limit before attempting to interpret the body as an API error. if res.StatusCode() == 429 { time.Sleep(time.Duration(i+1) * 2 * time.Second) continue } + body := res.Body() var apiErr apiError if err := json.Unmarshal(body, &apiErr); err == nil { if apiErr.Error != "" || apiErr.Message != "" { @@ -66,7 +65,6 @@ func (d *CFImgBed) doRequest(ctx context.Context, method, urlPath string, callba return nil, fmt.Errorf("HTTP %d", res.StatusCode()) } return body, nil - return body, nil } return nil, fmt.Errorf("max retries exceeded") }