Skip to content

Commit 88d256d

Browse files
syscccodex
andauthored
feat(s3): support direct transfer redirects (#2598)
Redirect S3 GET object requests to direct links when the underlying storage can provide one and proxying is not required. Redirect eligible S3 PUT object requests to HttpDirect single PUT upload URLs with 307, while falling back to the existing gofakes3 stream path for unsupported uploads. Allow OneDrive Sharelink direct upload info to use simple content PUT URLs for files within Microsoft's single-upload limit. Co-authored-by: syscc <syscc@users.noreply.github.com> Co-authored-by: Codex <codex@openai.com>
1 parent 5a92dc2 commit 88d256d

4 files changed

Lines changed: 248 additions & 3 deletions

File tree

drivers/onedrive_sharelink/driver.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const (
3333
headerTTL = 25 * time.Minute
3434
driveTokenTTL = 20 * time.Minute
3535
directLinkTTL = 20 * time.Minute
36+
simpleUploadLimit = 250 * 1024 * 1024
3637
uploadSessionChunk = 10 * 1024 * 1024
3738
)
3839

@@ -302,7 +303,7 @@ func (d *OnedriveSharelink) createUploadInfo(ctx context.Context, path string, f
302303
if err != nil {
303304
return nil, err
304305
}
305-
if fileSize == 0 {
306+
if fileSize >= 0 && fileSize <= simpleUploadLimit {
306307
return &model.HttpDirectUploadInfo{
307308
UploadURL: injectAccessToken(d.drivePathAPIURL(path)+"/content", token),
308309
Method: http.MethodPut,

server/s3/redirect.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// Credits: https://pkg.go.dev/github.com/rclone/rclone@v1.65.2/cmd/serve/s3
2+
// Package s3 implements a fake s3 server for openlist
3+
package s3
4+
5+
import (
6+
"context"
7+
"net"
8+
"net/http"
9+
"path"
10+
"strings"
11+
12+
"github.com/OpenListTeam/OpenList/v4/internal/conf"
13+
"github.com/OpenListTeam/OpenList/v4/internal/fs"
14+
"github.com/OpenListTeam/OpenList/v4/internal/model"
15+
"github.com/OpenListTeam/OpenList/v4/internal/op"
16+
"github.com/OpenListTeam/OpenList/v4/server/common"
17+
"github.com/itsHenry35/gofakes3/signature"
18+
)
19+
20+
func redirectHandler(next http.Handler, authPairs map[string]string) http.Handler {
21+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22+
if u, ok := directObjectURL(r, authPairs); ok {
23+
w.Header().Set("Location", u)
24+
w.WriteHeader(http.StatusFound)
25+
return
26+
}
27+
if u, ok := directUploadURL(r, authPairs); ok {
28+
w.Header().Set("Location", u)
29+
w.Header().Set("Cache-Control", "no-store")
30+
w.WriteHeader(http.StatusTemporaryRedirect)
31+
return
32+
}
33+
next.ServeHTTP(w, r)
34+
})
35+
}
36+
37+
func directObjectURL(r *http.Request, authPairs map[string]string) (string, bool) {
38+
if r.Method != http.MethodGet {
39+
return "", false
40+
}
41+
if hasNonObjectQuery(r) || !s3RequestAuthorized(r, authPairs) {
42+
return "", false
43+
}
44+
bucketName, objectName, ok := parseObjectPath(r.URL.Path)
45+
if !ok {
46+
return "", false
47+
}
48+
bucket, err := getBucketByName(bucketName)
49+
if err != nil {
50+
return "", false
51+
}
52+
reqPath := path.Join(bucket.Path, objectName)
53+
meta, _ := op.GetNearestMeta(reqPath)
54+
ctx := context.WithValue(r.Context(), conf.MetaKey, meta)
55+
storage, err := fs.GetStorage(reqPath, &fs.GetStoragesArgs{})
56+
if err != nil || common.ShouldProxy(storage, path.Base(reqPath)) {
57+
return "", false
58+
}
59+
link, file, err := fs.Link(ctx, reqPath, model.LinkArgs{
60+
IP: clientIP(r),
61+
Header: r.Header,
62+
Redirect: true,
63+
})
64+
if err != nil {
65+
return "", false
66+
}
67+
defer link.Close()
68+
if file == nil || file.IsDir() || link.URL == "" || link.RangeReader != nil {
69+
return "", false
70+
}
71+
return link.URL, true
72+
}
73+
74+
func directUploadURL(r *http.Request, authPairs map[string]string) (string, bool) {
75+
if r.Method != http.MethodPut || r.ContentLength < 0 {
76+
return "", false
77+
}
78+
if hasNonObjectQuery(r) || !s3RequestAuthorized(r, authPairs) {
79+
return "", false
80+
}
81+
if r.Header.Get("X-Amz-Copy-Source") != "" ||
82+
r.Header.Get("X-Amz-Content-Sha256") == "STREAMING-AWS4-HMAC-SHA256-PAYLOAD" {
83+
return "", false
84+
}
85+
bucketName, objectName, ok := parseObjectPath(r.URL.Path)
86+
if !ok || strings.HasSuffix(objectName, "/") {
87+
return "", false
88+
}
89+
bucket, err := getBucketByName(bucketName)
90+
if err != nil {
91+
return "", false
92+
}
93+
reqPath := path.Join(bucket.Path, objectName)
94+
storage, dstDirActualPath, err := op.GetStorageAndActualPath(path.Dir(reqPath))
95+
if err != nil || storage.Config().NoUpload {
96+
return "", false
97+
}
98+
info, err := op.GetDirectUploadInfo(r.Context(), "HttpDirect", storage, dstDirActualPath, path.Base(reqPath), r.ContentLength)
99+
if err != nil {
100+
return "", false
101+
}
102+
httpInfo, ok := asHTTPDirectUploadInfo(info)
103+
if !ok {
104+
return "", false
105+
}
106+
method := httpInfo.Method
107+
if method == "" {
108+
method = http.MethodPut
109+
}
110+
if !strings.EqualFold(method, http.MethodPut) || httpInfo.UploadURL == "" ||
111+
httpInfo.ChunkSize > 0 || len(httpInfo.Headers) > 0 {
112+
return "", false
113+
}
114+
return httpInfo.UploadURL, true
115+
}
116+
117+
func asHTTPDirectUploadInfo(info any) (*model.HttpDirectUploadInfo, bool) {
118+
switch v := info.(type) {
119+
case *model.HttpDirectUploadInfo:
120+
return v, v != nil
121+
case model.HttpDirectUploadInfo:
122+
return &v, true
123+
default:
124+
return nil, false
125+
}
126+
}
127+
128+
func parseObjectPath(rawPath string) (bucket, object string, ok bool) {
129+
parts := strings.SplitN(strings.TrimPrefix(rawPath, "/"), "/", 2)
130+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
131+
return "", "", false
132+
}
133+
return parts[0], parts[1], true
134+
}
135+
136+
func hasNonObjectQuery(r *http.Request) bool {
137+
query := r.URL.Query()
138+
for key := range query {
139+
lower := strings.ToLower(key)
140+
if strings.HasPrefix(lower, "x-amz-") ||
141+
lower == "awsaccesskeyid" ||
142+
lower == "signature" ||
143+
lower == "expires" ||
144+
lower == "x-id" {
145+
continue
146+
}
147+
return true
148+
}
149+
return false
150+
}
151+
152+
func s3RequestAuthorized(r *http.Request, authPairs map[string]string) bool {
153+
if len(authPairs) == 0 {
154+
return true
155+
}
156+
result := signature.V4SignVerify(r)
157+
if result == signature.ErrUnsupportAlgorithm {
158+
result = signature.V2SignVerify(r)
159+
}
160+
return result == signature.ErrNone
161+
}
162+
163+
func clientIP(r *http.Request) string {
164+
host, _, err := net.SplitHostPort(r.RemoteAddr)
165+
if err != nil {
166+
return r.RemoteAddr
167+
}
168+
return host
169+
}

server/s3/redirect_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package s3
2+
3+
import (
4+
"net/http/httptest"
5+
"testing"
6+
7+
"github.com/OpenListTeam/OpenList/v4/internal/model"
8+
)
9+
10+
func TestParseObjectPath(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
path string
14+
wantBucket string
15+
wantObject string
16+
wantOK bool
17+
}{
18+
{name: "object", path: "/bucket/path/to/file.txt", wantBucket: "bucket", wantObject: "path/to/file.txt", wantOK: true},
19+
{name: "missing object", path: "/bucket", wantOK: false},
20+
{name: "empty bucket", path: "//file.txt", wantOK: false},
21+
{name: "empty object", path: "/bucket/", wantOK: false},
22+
}
23+
24+
for _, tt := range tests {
25+
t.Run(tt.name, func(t *testing.T) {
26+
bucket, object, ok := parseObjectPath(tt.path)
27+
if ok != tt.wantOK || bucket != tt.wantBucket || object != tt.wantObject {
28+
t.Fatalf("parseObjectPath(%q) = (%q, %q, %v), want (%q, %q, %v)",
29+
tt.path, bucket, object, ok, tt.wantBucket, tt.wantObject, tt.wantOK)
30+
}
31+
})
32+
}
33+
}
34+
35+
func TestHasNonObjectQuery(t *testing.T) {
36+
tests := []struct {
37+
name string
38+
raw string
39+
want bool
40+
}{
41+
{name: "no query", raw: "/bucket/object", want: false},
42+
{name: "aws auth query", raw: "/bucket/object?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=abc", want: false},
43+
{name: "legacy auth query", raw: "/bucket/object?AWSAccessKeyId=ak&Signature=sig&Expires=1", want: false},
44+
{name: "sdk operation marker", raw: "/bucket/object?x-id=GetObject", want: false},
45+
{name: "list query", raw: "/bucket/object?list-type=2", want: true},
46+
}
47+
48+
for _, tt := range tests {
49+
t.Run(tt.name, func(t *testing.T) {
50+
req := httptest.NewRequest("GET", tt.raw, nil)
51+
if got := hasNonObjectQuery(req); got != tt.want {
52+
t.Fatalf("hasNonObjectQuery(%q) = %v, want %v", tt.raw, got, tt.want)
53+
}
54+
})
55+
}
56+
}
57+
58+
func TestAsHTTPDirectUploadInfo(t *testing.T) {
59+
info := model.HttpDirectUploadInfo{UploadURL: "https://example.com/upload"}
60+
got, ok := asHTTPDirectUploadInfo(info)
61+
if !ok || got == nil || got.UploadURL != info.UploadURL {
62+
t.Fatalf("asHTTPDirectUploadInfo(value) = (%v, %v), want upload info", got, ok)
63+
}
64+
65+
got, ok = asHTTPDirectUploadInfo(&info)
66+
if !ok || got == nil || got.UploadURL != info.UploadURL {
67+
t.Fatalf("asHTTPDirectUploadInfo(pointer) = (%v, %v), want upload info", got, ok)
68+
}
69+
70+
got, ok = asHTTPDirectUploadInfo(struct{}{})
71+
if ok || got != nil {
72+
t.Fatalf("asHTTPDirectUploadInfo(unsupported) = (%v, %v), want nil false", got, ok)
73+
}
74+
}

server/s3/server.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,16 @@ import (
1313
// Make a new S3 Server to serve the remote
1414
func NewServer(ctx context.Context) (h http.Handler, err error) {
1515
var newLogger logger
16+
authPairs := authlistResolver()
1617
faker := gofakes3.New(
1718
newBackend(),
1819
// gofakes3.WithHostBucket(!opt.pathBucketMode),
1920
gofakes3.WithLogger(newLogger),
2021
gofakes3.WithRequestID(rand.Uint64()),
2122
gofakes3.WithoutVersioning(),
22-
gofakes3.WithV4Auth(authlistResolver()),
23+
gofakes3.WithV4Auth(authPairs),
2324
gofakes3.WithIntegrityCheck(true), // Check Content-MD5 if supplied
2425
)
2526

26-
return faker.Server(), nil
27+
return redirectHandler(faker.Server(), authPairs), nil
2728
}

0 commit comments

Comments
 (0)