From f6dc405a617851204c79b43d22260fa293caac45 Mon Sep 17 00:00:00 2001 From: Demogest Date: Tue, 12 May 2026 23:56:31 +0800 Subject: [PATCH 1/2] feat(driver/bunny_storage): add Bunny Storage driver Add Bunny Storage API support with optional CDN links and token signing. Cover CDN mount path handling, token signing, and proxy/cache behavior with unit tests. --- drivers/all.go | 1 + drivers/bunny_storage/driver.go | 236 ++++++++++++++++++++++++++ drivers/bunny_storage/meta.go | 40 +++++ drivers/bunny_storage/types.go | 27 +++ drivers/bunny_storage/util.go | 262 +++++++++++++++++++++++++++++ drivers/bunny_storage/util_test.go | 193 +++++++++++++++++++++ 6 files changed, 759 insertions(+) create mode 100644 drivers/bunny_storage/driver.go create mode 100644 drivers/bunny_storage/meta.go create mode 100644 drivers/bunny_storage/types.go create mode 100644 drivers/bunny_storage/util.go create mode 100644 drivers/bunny_storage/util_test.go diff --git a/drivers/all.go b/drivers/all.go index fb68d0395..0df93fdfa 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -21,6 +21,7 @@ import ( _ "github.com/OpenListTeam/OpenList/v4/drivers/azure_blob" _ "github.com/OpenListTeam/OpenList/v4/drivers/baidu_netdisk" _ "github.com/OpenListTeam/OpenList/v4/drivers/baidu_photo" + _ "github.com/OpenListTeam/OpenList/v4/drivers/bunny_storage" _ "github.com/OpenListTeam/OpenList/v4/drivers/chaoxing" _ "github.com/OpenListTeam/OpenList/v4/drivers/chunk" _ "github.com/OpenListTeam/OpenList/v4/drivers/cloudreve" diff --git a/drivers/bunny_storage/driver.go b/drivers/bunny_storage/driver.go new file mode 100644 index 000000000..ddf8be54b --- /dev/null +++ b/drivers/bunny_storage/driver.go @@ -0,0 +1,236 @@ +package bunny_storage + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + stdpath "path" + "strings" + "time" + + "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/internal/op" + "github.com/go-resty/resty/v2" +) + +type BunnyStorage struct { + model.Storage + Addition + client *resty.Client + endpoint *url.URL + cdnBase *url.URL +} + +func (d *BunnyStorage) Config() driver.Config { + cfg := config + if d.StorageZoneName != "" && d.CDNBaseURL == "" { + cfg.OnlyProxy = true + cfg.PreferProxy = true + } + if d.CDNTokenKey != "" && d.CDNTokenIncludeIP { + cfg.LinkCacheMode = driver.LinkCacheIP + } + return cfg +} + +func (d *BunnyStorage) GetAddition() driver.Additional { + return &d.Addition +} + +func (d *BunnyStorage) Init(ctx context.Context) error { + if d.RootFolderPath == "" { + d.RootFolderPath = "/" + } + if d.Endpoint == "" { + d.Endpoint = defaultEndpoint + } + if d.SignURLExpire <= 0 { + d.SignURLExpire = 4 + } + if d.CDNTokenMethod == "" { + d.CDNTokenMethod = cdnTokenMethodSHA256 + } + endpoint, err := normalizeBaseURL(d.Endpoint, defaultEndpoint) + if err != nil { + return fmt.Errorf("invalid endpoint: %w", err) + } + d.endpoint = endpoint + if d.CDNBaseURL != "" { + cdnBase, err := normalizeBaseURL(d.CDNBaseURL, "") + if err != nil { + return fmt.Errorf("invalid cdn_base_url: %w", err) + } + d.cdnBase = cdnBase + } + d.client = base.RestyClient + if d.client == nil { + d.client = base.NewRestyClient() + } + return nil +} + +func (d *BunnyStorage) Drop(ctx context.Context) error { + return nil +} + +func (d *BunnyStorage) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + var items []bunnyObject + resp, err := d.authRequest(). + SetContext(ctx). + SetResult(&items). + Get(d.storageURL(dir.GetPath(), true)) + if err != nil { + return nil, err + } + if err := d.handleResponseError(resp); err != nil { + return nil, err + } + result := make([]model.Obj, 0, len(items)) + placeholder := d.placeholderName() + for _, item := range items { + if item.ObjectName == "" { + continue + } + if !args.S3ShowPlaceholder && !item.IsDirectory && item.ObjectName == placeholder { + continue + } + result = append(result, d.toObj(dir.GetPath(), item)) + } + return result, nil +} + +func (d *BunnyStorage) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + if file.IsDir() { + return nil, errs.NotFile + } + cacheTTL := time.Duration(0) + if d.cdnBase != nil { + linkURL := d.cdnURL(d.cdnObjectPath(file.GetPath())) + link := &model.Link{ + URL: linkURL, + ContentLength: file.GetSize(), + Expiration: &cacheTTL, + } + if d.CDNTokenKey != "" { + signedURL, _, err := d.signCDNURL(linkURL, args.IP) + if err != nil { + return nil, err + } + link.URL = signedURL + } + return link, nil + } + return &model.Link{ + URL: d.storageURL(file.GetPath(), false), + Header: http.Header{"AccessKey": []string{d.AccessKey}}, + ContentLength: file.GetSize(), + Expiration: &cacheTTL, + }, nil +} + +func (d *BunnyStorage) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + dirPath := stdpath.Join(parentDir.GetPath(), dirName) + placeholderPath := stdpath.Join(dirPath, d.placeholderName()) + if err := d.putReader(ctx, placeholderPath, bytes.NewReader(nil), 0, "application/octet-stream", nil); err != nil { + return nil, err + } + now := time.Now() + return &model.Object{ + Path: dirPath, + Name: dirName, + Modified: now, + Ctime: now, + IsFolder: true, + }, nil +} + +func (d *BunnyStorage) Remove(ctx context.Context, obj model.Obj) error { + resp, err := d.authRequest(). + SetContext(ctx). + Delete(d.storageURL(obj.GetPath(), obj.IsDir())) + if err != nil { + return err + } + return d.handleResponseError(resp) +} + +func (d *BunnyStorage) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + if up == nil { + up = func(float64) {} + } + dstPath := stdpath.Join(dstDir.GetPath(), file.GetName()) + err := d.putReader(ctx, dstPath, driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{ + Reader: file, + UpdateProgress: up, + }), file.GetSize(), file.GetMimetype(), nil) + if err != nil { + return nil, err + } + now := time.Now() + return &model.Object{ + Path: dstPath, + Name: file.GetName(), + Size: file.GetSize(), + Modified: now, + Ctime: now, + }, nil +} + +func (d *BunnyStorage) putReader(ctx context.Context, path string, body any, size int64, contentType string, extraHeaders http.Header) error { + if contentType == "" { + contentType = "application/octet-stream" + } + req := d.authRequest(). + SetContext(ctx). + SetBody(body). + SetHeader("Content-Type", contentType) + if size >= 0 { + req.SetHeader("Content-Length", fmt.Sprint(size)) + } + for key, values := range extraHeaders { + for _, value := range values { + req.SetHeader(key, value) + } + } + resp, err := req.Put(d.storageURL(path, false)) + if err != nil { + return err + } + return d.handleResponseError(resp) +} + +func (d *BunnyStorage) Get(ctx context.Context, path string) (model.Obj, error) { + fullPath := stdpath.Join(d.GetRootPath(), path) + if cleanObjectPath(fullPath) == "/" { + return &model.Object{ + Path: d.GetRootPath(), + Name: op.RootName, + Modified: d.Modified, + IsFolder: true, + Mask: model.Locked, + }, nil + } + parentPath, name := stdpath.Split(fullPath) + parentPath = strings.TrimSuffix(parentPath, "/") + if parentPath == "" { + parentPath = "/" + } + objs, err := d.List(ctx, &model.Object{Path: parentPath, IsFolder: true}, model.ListArgs{S3ShowPlaceholder: true}) + if err != nil { + return nil, err + } + for _, obj := range objs { + if obj.GetName() == name { + return obj, nil + } + } + return nil, errs.ObjectNotFound +} + +var _ driver.Driver = (*BunnyStorage)(nil) +var _ driver.Getter = (*BunnyStorage)(nil) diff --git a/drivers/bunny_storage/meta.go b/drivers/bunny_storage/meta.go new file mode 100644 index 000000000..e74283fa9 --- /dev/null +++ b/drivers/bunny_storage/meta.go @@ -0,0 +1,40 @@ +package bunny_storage + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + RootFolderPath string `json:"root_folder_path" default:"/" required:"true"` + StorageZoneName string `json:"storage_zone_name" required:"true"` + AccessKey string `json:"access_key" required:"true"` + Endpoint string `json:"endpoint" required:"true" default:"storage.bunnycdn.com"` + CDNBaseURL string `json:"cdn_base_url"` + CDNTokenKey string `json:"cdn_token_key"` + CDNTokenMethod string `json:"cdn_token_method" type:"select" options:"sha256,hmac_sha256" default:"sha256"` + CDNTokenIncludeIP bool `json:"cdn_token_include_ip" default:"false"` + SignURLExpire int `json:"sign_url_expire" type:"number" default:"4"` + Placeholder string `json:"placeholder" default:".openlist"` +} + +func (a Addition) GetRootPath() string { + return a.RootFolderPath +} + +func (a *Addition) SetRootPath(path string) { + a.RootFolderPath = path +} + +var config = driver.Config{ + Name: "Bunny Storage", + LocalSort: true, + DefaultRoot: "/", + CheckStatus: true, +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &BunnyStorage{} + }) +} diff --git a/drivers/bunny_storage/types.go b/drivers/bunny_storage/types.go new file mode 100644 index 000000000..0a4d1016a --- /dev/null +++ b/drivers/bunny_storage/types.go @@ -0,0 +1,27 @@ +package bunny_storage + +import "time" + +type bunnyObject struct { + Guid string `json:"Guid"` + StorageZoneName string `json:"StorageZoneName"` + Path string `json:"Path"` + ObjectName string `json:"ObjectName"` + Length int64 `json:"Length"` + LastChanged string `json:"LastChanged"` + IsDirectory bool `json:"IsDirectory"` + ServerID int `json:"ServerId"` + UserID string `json:"UserId"` + DateCreated string `json:"DateCreated"` + StorageZoneID int64 `json:"StorageZoneId"` +} + +type apiError struct { + HttpCode int `json:"HttpCode"` + Message string `json:"Message"` +} + +type parsedTimes struct { + modified time.Time + created time.Time +} diff --git a/drivers/bunny_storage/util.go b/drivers/bunny_storage/util.go new file mode 100644 index 000000000..cc10c9111 --- /dev/null +++ b/drivers/bunny_storage/util.go @@ -0,0 +1,262 @@ +package bunny_storage + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + stdpath "path" + "sort" + "strconv" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/go-resty/resty/v2" +) + +const ( + defaultEndpoint = "storage.bunnycdn.com" + defaultPlaceholder = ".openlist" + + cdnTokenMethodSHA256 = "sha256" + cdnTokenMethodHMACSHA256 = "hmac_sha256" +) + +func normalizeBaseURL(raw string, fallback string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + raw = fallback + } + if raw == "" { + return nil, fmt.Errorf("empty url") + } + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + u, err := url.Parse(raw) + if err != nil { + return nil, err + } + if u.Host == "" { + return nil, fmt.Errorf("invalid url: %s", raw) + } + u.Path = strings.TrimRight(u.Path, "/") + return u, nil +} + +func cleanObjectPath(path string) string { + if path == "" { + return "/" + } + return stdpath.Clean("/" + strings.TrimPrefix(path, "/")) +} + +func stripObjectPathPrefix(path string, prefix string) (string, bool) { + path = cleanObjectPath(path) + prefix = cleanObjectPath(prefix) + if prefix == "/" { + return path, false + } + if path == prefix { + return "/", true + } + if strings.HasPrefix(path, prefix+"/") { + return cleanObjectPath(strings.TrimPrefix(path, prefix)), true + } + return path, false +} + +func isObjectPathOrChild(path string, parent string) bool { + path = cleanObjectPath(path) + parent = cleanObjectPath(parent) + return path == parent || strings.HasPrefix(path, parent+"/") +} + +func trimCDNBasePath(path string, mountPath string) string { + path = cleanObjectPath(path) + if path == "/" { + return "" + } + if stripped, ok := stripObjectPathPrefix(path, mountPath); ok { + path = stripped + } + if path == "/" { + return "" + } + return strings.TrimRight(path, "/") +} + +func (d *BunnyStorage) cdnObjectPath(path string) string { + objectPath := cleanObjectPath(path) + if stripped, ok := stripObjectPathPrefix(objectPath, d.GetStorage().MountPath); ok { + objectPath = stripped + } + rootPath := cleanObjectPath(d.GetRootPath()) + if rootPath != "/" && !isObjectPathOrChild(objectPath, rootPath) { + objectPath = cleanObjectPath(stdpath.Join(rootPath, objectPath)) + } + return objectPath +} + +func (d *BunnyStorage) placeholderName() string { + if d.Placeholder == "" { + return defaultPlaceholder + } + return d.Placeholder +} + +func (d *BunnyStorage) storageURL(path string, dir bool) string { + u := *d.endpoint + cleanPath := cleanObjectPath(path) + zone := strings.Trim(d.StorageZoneName, "/") + if cleanPath == "/" { + u.Path = "/" + zone + "/" + return u.String() + } + u.Path = "/" + zone + "/" + strings.TrimPrefix(cleanPath, "/") + if dir && !strings.HasSuffix(u.Path, "/") { + u.Path += "/" + } + return u.String() +} + +func (d *BunnyStorage) cdnURL(path string) string { + u := *d.cdnBase + cleanPath := cleanObjectPath(path) + basePath := trimCDNBasePath(u.Path, d.GetStorage().MountPath) + if cleanPath == "/" { + if basePath == "" { + u.Path = "/" + } else { + u.Path = basePath + "/" + } + return u.String() + } + u.Path = basePath + "/" + strings.TrimPrefix(cleanPath, "/") + return u.String() +} + +func (d *BunnyStorage) authRequest() *resty.Request { + return d.client.R().SetHeader("AccessKey", d.AccessKey) +} + +func (d *BunnyStorage) handleResponseError(resp *resty.Response) error { + if resp == nil { + return fmt.Errorf("empty response") + } + if resp.StatusCode() >= http.StatusOK && resp.StatusCode() < http.StatusMultipleChoices { + return nil + } + message := strings.TrimSpace(resp.String()) + var apiErrors []apiError + if err := json.Unmarshal(resp.Body(), &apiErrors); err == nil && len(apiErrors) > 0 && apiErrors[0].Message != "" { + message = apiErrors[0].Message + } + switch resp.StatusCode() { + case http.StatusUnauthorized, http.StatusForbidden: + return errs.NewErr(errs.PermissionDenied, "bunny storage request failed: %s", message) + case http.StatusNotFound: + return errs.NewErr(errs.ObjectNotFound, "bunny storage request failed: %s", message) + default: + return fmt.Errorf("bunny storage request failed: %s: %s", resp.Status(), message) + } +} + +func (d *BunnyStorage) parseTimes(item bunnyObject) parsedTimes { + return parsedTimes{ + modified: parseBunnyTime(item.LastChanged, d.Modified), + created: parseBunnyTime(item.DateCreated, time.Time{}), + } +} + +func parseBunnyTime(value string, fallback time.Time) time.Time { + if value == "" { + return fallback + } + if t, err := time.Parse(time.RFC3339Nano, value); err == nil { + return t + } + if t, err := time.Parse("2006-01-02T15:04:05", value); err == nil { + return t + } + return fallback +} + +func (d *BunnyStorage) toObj(parentPath string, item bunnyObject) model.Obj { + times := d.parseTimes(item) + return &model.Object{ + ID: item.Guid, + Path: stdpath.Join(parentPath, item.ObjectName), + Name: item.ObjectName, + Size: item.Length, + Modified: times.modified, + Ctime: times.created, + IsFolder: item.IsDirectory, + } +} + +func canonicalQuery(values url.Values) string { + keys := make([]string, 0, len(values)) + for key := range values { + if key == "token" || key == "expires" { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + vals := values[key] + parts = append(parts, key+"="+strings.Join(vals, "")) + } + return strings.Join(parts, "&") +} + +func (d *BunnyStorage) signCDNURL(rawURL string, clientIP string) (string, time.Duration, error) { + return d.signCDNURLAt(rawURL, clientIP, time.Now()) +} + +func (d *BunnyStorage) signCDNURLAt(rawURL string, clientIP string, now time.Time) (string, time.Duration, error) { + expire := time.Hour * time.Duration(d.SignURLExpire) + if expire <= 0 { + expire = 4 * time.Hour + } + expires := now.Add(expire).Unix() + u, err := url.Parse(rawURL) + if err != nil { + return "", 0, err + } + query := u.Query() + parameterData := canonicalQuery(query) + signaturePath, err := url.QueryUnescape(u.EscapedPath()) + if err != nil { + signaturePath = u.Path + } + if !d.CDNTokenIncludeIP { + clientIP = "" + } + token := d.signCDNToken(signaturePath, strconv.FormatInt(expires, 10), parameterData, clientIP) + query.Set("token", token) + query.Set("expires", strconv.FormatInt(expires, 10)) + u.RawQuery = query.Encode() + return u.String(), expire, nil +} + +func (d *BunnyStorage) signCDNToken(signaturePath string, expires string, parameterData string, clientIP string) string { + switch strings.ToLower(strings.TrimSpace(d.CDNTokenMethod)) { + case cdnTokenMethodHMACSHA256: + message := signaturePath + expires + parameterData + clientIP + mac := hmac.New(sha256.New, []byte(d.CDNTokenKey)) + _, _ = mac.Write([]byte(message)) + return "HS256-" + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + default: + hashableBase := d.CDNTokenKey + signaturePath + expires + parameterData + clientIP + sum := sha256.Sum256([]byte(hashableBase)) + return base64.RawURLEncoding.EncodeToString(sum[:]) + } +} diff --git a/drivers/bunny_storage/util_test.go b/drivers/bunny_storage/util_test.go new file mode 100644 index 000000000..4d48bf83a --- /dev/null +++ b/drivers/bunny_storage/util_test.go @@ -0,0 +1,193 @@ +package bunny_storage + +import ( + "context" + "net/url" + "testing" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +func TestStorageURL(t *testing.T) { + endpoint, err := normalizeBaseURL("ny.storage.bunnycdn.com", defaultEndpoint) + if err != nil { + t.Fatal(err) + } + driver := &BunnyStorage{ + Addition: Addition{ + StorageZoneName: "my-zone", + }, + endpoint: endpoint, + } + if got, want := driver.storageURL("/", true), "https://ny.storage.bunnycdn.com/my-zone/"; got != want { + t.Fatalf("root list url = %q, want %q", got, want) + } + if got, want := driver.storageURL("/dir/a file.txt", false), "https://ny.storage.bunnycdn.com/my-zone/dir/a%20file.txt"; got != want { + t.Fatalf("file url = %q, want %q", got, want) + } + if got, want := driver.storageURL("/dir", true), "https://ny.storage.bunnycdn.com/my-zone/dir/"; got != want { + t.Fatalf("dir url = %q, want %q", got, want) + } +} + +func TestCDNURLWithBasePath(t *testing.T) { + cdnBase, err := normalizeBaseURL("https://cdn.example.com/prefix/", "") + if err != nil { + t.Fatal(err) + } + driver := &BunnyStorage{cdnBase: cdnBase} + if got, want := driver.cdnURL("/dir/a file.txt"), "https://cdn.example.com/prefix/dir/a%20file.txt"; got != want { + t.Fatalf("cdn url = %q, want %q", got, want) + } +} + +func TestCDNURLUsesObjectPathWithoutMountPath(t *testing.T) { + cdnBase, err := normalizeBaseURL("https://cdn.firmant.me", "") + if err != nil { + t.Fatal(err) + } + driver := &BunnyStorage{ + Storage: model.Storage{MountPath: "/BS"}, + cdnBase: cdnBase, + } + if got, want := driver.cdnURL(driver.cdnObjectPath("/BS/Video")), "https://cdn.firmant.me/Video"; got != want { + t.Fatalf("cdn url = %q, want %q", got, want) + } +} + +func TestCDNURLDropsMountPathFromBaseURL(t *testing.T) { + cdnBase, err := normalizeBaseURL("https://cdn.firmant.me/BS/", "") + if err != nil { + t.Fatal(err) + } + driver := &BunnyStorage{ + Storage: model.Storage{MountPath: "/BS"}, + cdnBase: cdnBase, + } + if got, want := driver.cdnURL(driver.cdnObjectPath("/Video")), "https://cdn.firmant.me/Video"; got != want { + t.Fatalf("cdn url = %q, want %q", got, want) + } +} + +func TestCDNObjectPathKeepsRootFolderPath(t *testing.T) { + driver := &BunnyStorage{ + Storage: model.Storage{MountPath: "/BS"}, + Addition: Addition{ + RootFolderPath: "/library", + }, + } + if got, want := driver.cdnObjectPath("/BS/Video"), "/library/Video"; got != want { + t.Fatalf("cdn object path = %q, want %q", got, want) + } + if got, want := driver.cdnObjectPath("/library/Video"), "/library/Video"; got != want { + t.Fatalf("cdn object path = %q, want %q", got, want) + } +} + +func TestLinkDisablesLongLivedCache(t *testing.T) { + cdnBase, err := normalizeBaseURL("https://cdn.example.com", "") + if err != nil { + t.Fatal(err) + } + driver := &BunnyStorage{cdnBase: cdnBase} + link, err := driver.Link(context.Background(), &model.Object{ + Path: "/video.mp4", + Name: "video.mp4", + Size: 123, + }, model.LinkArgs{}) + if err != nil { + t.Fatal(err) + } + if link.Expiration == nil || *link.Expiration != 0 { + t.Fatalf("link expiration = %v, want immediate cache expiration", link.Expiration) + } +} + +func TestSignCDNURL(t *testing.T) { + driver := &BunnyStorage{ + Addition: Addition{ + CDNTokenKey: "secret", + CDNTokenIncludeIP: true, + SignURLExpire: 1, + }, + } + signed, expire, err := driver.signCDNURLAt("https://zone.b-cdn.net/video.mp4?quality=high", "192.0.2.1", time.Unix(1700000000, 0)) + if err != nil { + t.Fatal(err) + } + if expire <= 0 { + t.Fatal("expected positive expiration") + } + parsed, err := url.Parse(signed) + if err != nil { + t.Fatal(err) + } + token := parsed.Query().Get("token") + if want := "FxSpFem88zFo6uHFziwTuoMQTgDaD2PEn5n1zTMBUBI"; token != want { + t.Fatalf("token = %q, want %q", token, want) + } + if parsed.Query().Get("expires") != "1700003600" { + t.Fatalf("expires = %q, want 1700003600", parsed.Query().Get("expires")) + } + if parsed.Query().Get("quality") != "high" { + t.Fatal("expected existing query parameters to be preserved") + } +} + +func TestSignCDNURLSupportsHMACSHA256(t *testing.T) { + driver := &BunnyStorage{ + Addition: Addition{ + CDNTokenKey: "secret", + CDNTokenMethod: cdnTokenMethodHMACSHA256, + CDNTokenIncludeIP: true, + SignURLExpire: 1, + }, + } + signed, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/video.mp4?quality=high", "192.0.2.1", time.Unix(1700000000, 0)) + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(signed) + if err != nil { + t.Fatal(err) + } + if got, want := parsed.Query().Get("token"), "HS256-sdrSSJE2JVwhSk2AoDUrmTV1muH6R5UHpZVcVfHeNxg"; got != want { + t.Fatalf("token = %q, want %q", got, want) + } +} + +func TestSignCDNURLUsesDecodedPathForSHA256(t *testing.T) { + driver := &BunnyStorage{ + Addition: Addition{ + CDNTokenKey: "secret", + SignURLExpire: 1, + }, + } + signed, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/%E8%A7%86%E9%A2%91/%5Ba%20b%5D.mp4", "", time.Unix(1700000000, 0)) + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(signed) + if err != nil { + t.Fatal(err) + } + if got, want := parsed.Query().Get("token"), "yq1evD7klw0e3DjCbv8dJptbW4S4JwVW3GKLnxfeKGM"; got != want { + t.Fatalf("token = %q, want %q", got, want) + } +} + +func TestConfigProxyMode(t *testing.T) { + registeredConfig := (&BunnyStorage{}).Config() + if registeredConfig.OnlyProxy { + t.Fatal("driver registration config should allow users to choose proxy policy") + } + withoutCDN := (&BunnyStorage{Addition: Addition{StorageZoneName: "my-zone"}}).Config() + if !withoutCDN.OnlyProxy { + t.Fatal("storage API links require AccessKey headers and should be proxied without CDN") + } + withCDN := (&BunnyStorage{Addition: Addition{StorageZoneName: "my-zone", CDNBaseURL: "https://zone.b-cdn.net"}}).Config() + if withCDN.OnlyProxy { + t.Fatal("CDN links should be allowed to redirect directly") + } +} From 667306e78a7939b093920c11c44aba18c34afe2b Mon Sep 17 00:00:00 2001 From: j2rong4cn Date: Wed, 13 May 2026 01:04:08 +0800 Subject: [PATCH 2/2] replace RootFolderPath with driver.RootPath in Addition struct --- drivers/bunny_storage/driver.go | 10 ---------- drivers/bunny_storage/meta.go | 10 +--------- drivers/bunny_storage/util_test.go | 9 +++------ 3 files changed, 4 insertions(+), 25 deletions(-) diff --git a/drivers/bunny_storage/driver.go b/drivers/bunny_storage/driver.go index ddf8be54b..c097da80d 100644 --- a/drivers/bunny_storage/driver.go +++ b/drivers/bunny_storage/driver.go @@ -14,7 +14,6 @@ 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/internal/op" "github.com/go-resty/resty/v2" ) @@ -206,15 +205,6 @@ func (d *BunnyStorage) putReader(ctx context.Context, path string, body any, siz func (d *BunnyStorage) Get(ctx context.Context, path string) (model.Obj, error) { fullPath := stdpath.Join(d.GetRootPath(), path) - if cleanObjectPath(fullPath) == "/" { - return &model.Object{ - Path: d.GetRootPath(), - Name: op.RootName, - Modified: d.Modified, - IsFolder: true, - Mask: model.Locked, - }, nil - } parentPath, name := stdpath.Split(fullPath) parentPath = strings.TrimSuffix(parentPath, "/") if parentPath == "" { diff --git a/drivers/bunny_storage/meta.go b/drivers/bunny_storage/meta.go index e74283fa9..46e135903 100644 --- a/drivers/bunny_storage/meta.go +++ b/drivers/bunny_storage/meta.go @@ -6,7 +6,7 @@ import ( ) type Addition struct { - RootFolderPath string `json:"root_folder_path" default:"/" required:"true"` + driver.RootPath StorageZoneName string `json:"storage_zone_name" required:"true"` AccessKey string `json:"access_key" required:"true"` Endpoint string `json:"endpoint" required:"true" default:"storage.bunnycdn.com"` @@ -18,14 +18,6 @@ type Addition struct { Placeholder string `json:"placeholder" default:".openlist"` } -func (a Addition) GetRootPath() string { - return a.RootFolderPath -} - -func (a *Addition) SetRootPath(path string) { - a.RootFolderPath = path -} - var config = driver.Config{ Name: "Bunny Storage", LocalSort: true, diff --git a/drivers/bunny_storage/util_test.go b/drivers/bunny_storage/util_test.go index 4d48bf83a..26cc37add 100644 --- a/drivers/bunny_storage/util_test.go +++ b/drivers/bunny_storage/util_test.go @@ -71,12 +71,9 @@ func TestCDNURLDropsMountPathFromBaseURL(t *testing.T) { } func TestCDNObjectPathKeepsRootFolderPath(t *testing.T) { - driver := &BunnyStorage{ - Storage: model.Storage{MountPath: "/BS"}, - Addition: Addition{ - RootFolderPath: "/library", - }, - } + driver := &BunnyStorage{} + driver.MountPath = "/BS" + driver.RootFolderPath = "/library" if got, want := driver.cdnObjectPath("/BS/Video"), "/library/Video"; got != want { t.Fatalf("cdn object path = %q, want %q", got, want) }