Skip to content

Commit 791a27e

Browse files
demogestj2rong4cnPIKACHUIMcodex
authored
feat(drivers): add Bunny Storage driver (#2472)
* 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. * replace RootFolderPath with driver.RootPath in Addition struct * fix(driver/bunny_storage): parse fractional Bunny timestamps * fix(bunny_storage): correct CDN token signing - Decode URL paths without treating literal plus signs as spaces. - Reject duplicate query parameters and cover both cases with regression tests. Co-authored-by: Codex <267193182+codex@users.noreply.github.com> --------- Co-authored-by: j2rong4cn <j2rong@qq.com> Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com> Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
1 parent 28cea1f commit 791a27e

6 files changed

Lines changed: 816 additions & 0 deletions

File tree

drivers/all.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
_ "github.com/OpenListTeam/OpenList/v4/drivers/azure_blob"
2323
_ "github.com/OpenListTeam/OpenList/v4/drivers/baidu_netdisk"
2424
_ "github.com/OpenListTeam/OpenList/v4/drivers/baidu_photo"
25+
_ "github.com/OpenListTeam/OpenList/v4/drivers/bunny_storage"
2526
_ "github.com/OpenListTeam/OpenList/v4/drivers/chaoxing"
2627
_ "github.com/OpenListTeam/OpenList/v4/drivers/chunk"
2728
_ "github.com/OpenListTeam/OpenList/v4/drivers/cloudflare_imgbed"

drivers/bunny_storage/driver.go

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
package bunny_storage
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"net/http"
8+
"net/url"
9+
stdpath "path"
10+
"strings"
11+
"time"
12+
13+
"github.com/OpenListTeam/OpenList/v4/drivers/base"
14+
"github.com/OpenListTeam/OpenList/v4/internal/driver"
15+
"github.com/OpenListTeam/OpenList/v4/internal/errs"
16+
"github.com/OpenListTeam/OpenList/v4/internal/model"
17+
"github.com/go-resty/resty/v2"
18+
)
19+
20+
type BunnyStorage struct {
21+
model.Storage
22+
Addition
23+
client *resty.Client
24+
endpoint *url.URL
25+
cdnBase *url.URL
26+
}
27+
28+
func (d *BunnyStorage) Config() driver.Config {
29+
cfg := config
30+
if d.StorageZoneName != "" && d.CDNBaseURL == "" {
31+
cfg.OnlyProxy = true
32+
cfg.PreferProxy = true
33+
}
34+
if d.CDNTokenKey != "" && d.CDNTokenIncludeIP {
35+
cfg.LinkCacheMode = driver.LinkCacheIP
36+
}
37+
return cfg
38+
}
39+
40+
func (d *BunnyStorage) GetAddition() driver.Additional {
41+
return &d.Addition
42+
}
43+
44+
func (d *BunnyStorage) Init(ctx context.Context) error {
45+
if d.RootFolderPath == "" {
46+
d.RootFolderPath = "/"
47+
}
48+
if d.Endpoint == "" {
49+
d.Endpoint = defaultEndpoint
50+
}
51+
if d.SignURLExpire <= 0 {
52+
d.SignURLExpire = 4
53+
}
54+
if d.CDNTokenMethod == "" {
55+
d.CDNTokenMethod = cdnTokenMethodSHA256
56+
}
57+
endpoint, err := normalizeBaseURL(d.Endpoint, defaultEndpoint)
58+
if err != nil {
59+
return fmt.Errorf("invalid endpoint: %w", err)
60+
}
61+
d.endpoint = endpoint
62+
if d.CDNBaseURL != "" {
63+
cdnBase, err := normalizeBaseURL(d.CDNBaseURL, "")
64+
if err != nil {
65+
return fmt.Errorf("invalid cdn_base_url: %w", err)
66+
}
67+
d.cdnBase = cdnBase
68+
}
69+
d.client = base.RestyClient
70+
if d.client == nil {
71+
d.client = base.NewRestyClient()
72+
}
73+
return nil
74+
}
75+
76+
func (d *BunnyStorage) Drop(ctx context.Context) error {
77+
return nil
78+
}
79+
80+
func (d *BunnyStorage) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
81+
var items []bunnyObject
82+
resp, err := d.authRequest().
83+
SetContext(ctx).
84+
SetResult(&items).
85+
Get(d.storageURL(dir.GetPath(), true))
86+
if err != nil {
87+
return nil, err
88+
}
89+
if err := d.handleResponseError(resp); err != nil {
90+
return nil, err
91+
}
92+
result := make([]model.Obj, 0, len(items))
93+
placeholder := d.placeholderName()
94+
for _, item := range items {
95+
if item.ObjectName == "" {
96+
continue
97+
}
98+
if !args.S3ShowPlaceholder && !item.IsDirectory && item.ObjectName == placeholder {
99+
continue
100+
}
101+
result = append(result, d.toObj(dir.GetPath(), item))
102+
}
103+
return result, nil
104+
}
105+
106+
func (d *BunnyStorage) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
107+
if file.IsDir() {
108+
return nil, errs.NotFile
109+
}
110+
cacheTTL := time.Duration(0)
111+
if d.cdnBase != nil {
112+
linkURL := d.cdnURL(d.cdnObjectPath(file.GetPath()))
113+
link := &model.Link{
114+
URL: linkURL,
115+
ContentLength: file.GetSize(),
116+
Expiration: &cacheTTL,
117+
}
118+
if d.CDNTokenKey != "" {
119+
signedURL, _, err := d.signCDNURL(linkURL, args.IP)
120+
if err != nil {
121+
return nil, err
122+
}
123+
link.URL = signedURL
124+
}
125+
return link, nil
126+
}
127+
return &model.Link{
128+
URL: d.storageURL(file.GetPath(), false),
129+
Header: http.Header{"AccessKey": []string{d.AccessKey}},
130+
ContentLength: file.GetSize(),
131+
Expiration: &cacheTTL,
132+
}, nil
133+
}
134+
135+
func (d *BunnyStorage) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
136+
dirPath := stdpath.Join(parentDir.GetPath(), dirName)
137+
placeholderPath := stdpath.Join(dirPath, d.placeholderName())
138+
if err := d.putReader(ctx, placeholderPath, bytes.NewReader(nil), 0, "application/octet-stream", nil); err != nil {
139+
return nil, err
140+
}
141+
now := time.Now()
142+
return &model.Object{
143+
Path: dirPath,
144+
Name: dirName,
145+
Modified: now,
146+
Ctime: now,
147+
IsFolder: true,
148+
}, nil
149+
}
150+
151+
func (d *BunnyStorage) Remove(ctx context.Context, obj model.Obj) error {
152+
resp, err := d.authRequest().
153+
SetContext(ctx).
154+
Delete(d.storageURL(obj.GetPath(), obj.IsDir()))
155+
if err != nil {
156+
return err
157+
}
158+
return d.handleResponseError(resp)
159+
}
160+
161+
func (d *BunnyStorage) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
162+
if up == nil {
163+
up = func(float64) {}
164+
}
165+
dstPath := stdpath.Join(dstDir.GetPath(), file.GetName())
166+
err := d.putReader(ctx, dstPath, driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{
167+
Reader: file,
168+
UpdateProgress: up,
169+
}), file.GetSize(), file.GetMimetype(), nil)
170+
if err != nil {
171+
return nil, err
172+
}
173+
now := time.Now()
174+
return &model.Object{
175+
Path: dstPath,
176+
Name: file.GetName(),
177+
Size: file.GetSize(),
178+
Modified: now,
179+
Ctime: now,
180+
}, nil
181+
}
182+
183+
func (d *BunnyStorage) putReader(ctx context.Context, path string, body any, size int64, contentType string, extraHeaders http.Header) error {
184+
if contentType == "" {
185+
contentType = "application/octet-stream"
186+
}
187+
req := d.authRequest().
188+
SetContext(ctx).
189+
SetBody(body).
190+
SetHeader("Content-Type", contentType)
191+
if size >= 0 {
192+
req.SetHeader("Content-Length", fmt.Sprint(size))
193+
}
194+
for key, values := range extraHeaders {
195+
for _, value := range values {
196+
req.SetHeader(key, value)
197+
}
198+
}
199+
resp, err := req.Put(d.storageURL(path, false))
200+
if err != nil {
201+
return err
202+
}
203+
return d.handleResponseError(resp)
204+
}
205+
206+
func (d *BunnyStorage) Get(ctx context.Context, path string) (model.Obj, error) {
207+
fullPath := stdpath.Join(d.GetRootPath(), path)
208+
parentPath, name := stdpath.Split(fullPath)
209+
parentPath = strings.TrimSuffix(parentPath, "/")
210+
if parentPath == "" {
211+
parentPath = "/"
212+
}
213+
objs, err := d.List(ctx, &model.Object{Path: parentPath, IsFolder: true}, model.ListArgs{S3ShowPlaceholder: true})
214+
if err != nil {
215+
return nil, err
216+
}
217+
for _, obj := range objs {
218+
if obj.GetName() == name {
219+
return obj, nil
220+
}
221+
}
222+
return nil, errs.ObjectNotFound
223+
}
224+
225+
var _ driver.Driver = (*BunnyStorage)(nil)
226+
var _ driver.Getter = (*BunnyStorage)(nil)

drivers/bunny_storage/meta.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package bunny_storage
2+
3+
import (
4+
"github.com/OpenListTeam/OpenList/v4/internal/driver"
5+
"github.com/OpenListTeam/OpenList/v4/internal/op"
6+
)
7+
8+
type Addition struct {
9+
driver.RootPath
10+
StorageZoneName string `json:"storage_zone_name" required:"true"`
11+
AccessKey string `json:"access_key" required:"true"`
12+
Endpoint string `json:"endpoint" required:"true" default:"storage.bunnycdn.com"`
13+
CDNBaseURL string `json:"cdn_base_url"`
14+
CDNTokenKey string `json:"cdn_token_key"`
15+
CDNTokenMethod string `json:"cdn_token_method" type:"select" options:"sha256,hmac_sha256" default:"sha256"`
16+
CDNTokenIncludeIP bool `json:"cdn_token_include_ip" default:"false"`
17+
SignURLExpire int `json:"sign_url_expire" type:"number" default:"4"`
18+
Placeholder string `json:"placeholder" default:".openlist"`
19+
}
20+
21+
var config = driver.Config{
22+
Name: "Bunny Storage",
23+
LocalSort: true,
24+
DefaultRoot: "/",
25+
CheckStatus: true,
26+
}
27+
28+
func init() {
29+
op.RegisterDriver(func() driver.Driver {
30+
return &BunnyStorage{}
31+
})
32+
}

drivers/bunny_storage/types.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package bunny_storage
2+
3+
import "time"
4+
5+
type bunnyObject struct {
6+
Guid string `json:"Guid"`
7+
StorageZoneName string `json:"StorageZoneName"`
8+
Path string `json:"Path"`
9+
ObjectName string `json:"ObjectName"`
10+
Length int64 `json:"Length"`
11+
LastChanged string `json:"LastChanged"`
12+
IsDirectory bool `json:"IsDirectory"`
13+
ServerID int `json:"ServerId"`
14+
UserID string `json:"UserId"`
15+
DateCreated string `json:"DateCreated"`
16+
StorageZoneID int64 `json:"StorageZoneId"`
17+
}
18+
19+
type apiError struct {
20+
HttpCode int `json:"HttpCode"`
21+
Message string `json:"Message"`
22+
}
23+
24+
type parsedTimes struct {
25+
modified time.Time
26+
created time.Time
27+
}

0 commit comments

Comments
 (0)