diff --git a/simplestorage/buckets_test.go b/simplestorage/buckets_test.go index 8901d63..c0e3ca6 100644 --- a/simplestorage/buckets_test.go +++ b/simplestorage/buckets_test.go @@ -75,7 +75,7 @@ func TestCreateBucket(t *testing.T) { if cleanup == nil { cleanup = func() func() { return func() {} } } - defer cleanup() + defer cleanup()() client, err := New(context.Background(), WithEndpoint("https://test.endpoint.dev"), @@ -128,7 +128,7 @@ func TestDeleteBucket(t *testing.T) { if cleanup == nil { cleanup = func() func() { return func() {} } } - defer cleanup() + defer cleanup()() client, err := New(context.Background(), WithEndpoint("https://test.endpoint.dev"), @@ -176,7 +176,7 @@ func TestListBuckets(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cleanup := tt.setupEnv - defer cleanup() + defer cleanup()() client, err := New(context.Background(), WithEndpoint("https://test.endpoint.dev"), @@ -219,7 +219,7 @@ func TestGetBucketInfo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cleanup := tt.setupEnv - defer cleanup() + defer cleanup()() // Create a client os.Setenv("TIGRIS_STORAGE_BUCKET", "dummy-bucket") diff --git a/simplestorage/client.go b/simplestorage/client.go index 3fc93c5..9982905 100644 --- a/simplestorage/client.go +++ b/simplestorage/client.go @@ -538,16 +538,25 @@ func (c *Client) PresignURL(ctx context.Context, method string, key string, expi switch method { case http.MethodGet: - return presignURLGet(ctx, presignClient, o.BucketName, key, expiry) + return presignURLGet(ctx, presignClient, o.BucketName, key, expiry, o) case http.MethodPut: return presignURLPut(ctx, presignClient, o.BucketName, key, expiry, o) case http.MethodDelete: - return presignURLDelete(ctx, presignClient, o.BucketName, key, expiry) + return presignURLDelete(ctx, presignClient, o.BucketName, key, expiry, o) } return "", nil // unreachable } +func presignOptions(expiry time.Duration, opts ClientOptions) []func(*s3.PresignOptions) { + return []func(*s3.PresignOptions){ + s3.WithPresignExpires(expiry), + func(po *s3.PresignOptions) { + po.ClientOptions = append(po.ClientOptions, opts.S3Options...) + }, + } +} + // generateRandomSuffix generates a random hexadecimal string of the specified length. // Uses (length+1)/2 random bytes so odd lengths still produce a hex string at // least length characters long before truncation. @@ -636,11 +645,11 @@ func raise[T comparable](v T) *T { } // presignURLGet generates a presigned URL for GET operations. -func presignURLGet(ctx context.Context, client *s3.PresignClient, bucket, key string, expiry time.Duration) (string, error) { +func presignURLGet(ctx context.Context, client *s3.PresignClient, bucket, key string, expiry time.Duration, opts ClientOptions) (string, error) { presignResult, err := client.PresignGetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), - }, s3.WithPresignExpires(expiry)) + }, presignOptions(expiry, opts)...) if err != nil { return "", fmt.Errorf("presign get: %w", err) } @@ -662,7 +671,7 @@ func presignURLPut(ctx context.Context, client *s3.PresignClient, bucket, key st input.ContentDisposition = opts.ContentDisposition } - presignResult, err := client.PresignPutObject(ctx, input, s3.WithPresignExpires(expiry)) + presignResult, err := client.PresignPutObject(ctx, input, presignOptions(expiry, opts)...) if err != nil { return "", fmt.Errorf("presign put: %w", err) } @@ -671,11 +680,11 @@ func presignURLPut(ctx context.Context, client *s3.PresignClient, bucket, key st } // presignURLDelete generates a presigned URL for DELETE operations. -func presignURLDelete(ctx context.Context, client *s3.PresignClient, bucket, key string, expiry time.Duration) (string, error) { +func presignURLDelete(ctx context.Context, client *s3.PresignClient, bucket, key string, expiry time.Duration, opts ClientOptions) (string, error) { presignResult, err := client.PresignDeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), - }, s3.WithPresignExpires(expiry)) + }, presignOptions(expiry, opts)...) if err != nil { return "", fmt.Errorf("presign delete: %w", err) } diff --git a/simplestorage/presign_example_test.go b/simplestorage/presign_example_test.go index b13c744..b30558f 100644 --- a/simplestorage/presign_example_test.go +++ b/simplestorage/presign_example_test.go @@ -5,9 +5,11 @@ import ( "fmt" "log" "net/http" + "strings" "time" simplestorage "github.com/tigrisdata/storage-go/simplestorage" + "github.com/tigrisdata/storage-go/tigrisheaders" ) func ExampleClient_PresignURL_get() { @@ -52,6 +54,47 @@ func ExampleClient_PresignURL_put() { fmt.Println("Presigned PUT URL:", url) } +func ExampleClient_PresignURL_signedTigrisHeaders() { + ctx := context.Background() + + client, err := simplestorage.New(ctx, + simplestorage.WithBucket("my-default-bucket"), + ) + if err != nil { + log.Fatal(err) + } + + // Headers passed through WithS3Options are part of the signature. The + // client using the URL must send them with the same values, or Tigris + // rejects the upload because the signature will not match. + url, err := client.PresignURL(ctx, http.MethodPut, "uploads/model.bin", 15*time.Minute, + simplestorage.WithContentType("application/octet-stream"), + simplestorage.WithS3Options( + tigrisheaders.WithStaticReplicationRegions([]tigrisheaders.Region{tigrisheaders.IAD, tigrisheaders.FRA}), + tigrisheaders.WithHeader("x-amz-meta-owner", "alice"), + ), + ) + if err != nil { + log.Fatal(err) // handle the error here + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, strings.NewReader("model bytes")) + if err != nil { + log.Fatal(err) // handle the error here + } + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("X-Tigris-Regions", "iad,fra") + req.Header.Set("x-amz-meta-owner", "alice") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Fatal(err) // handle the error here + } + defer resp.Body.Close() + + fmt.Println("Upload status:", resp.Status) +} + func ExampleClient_PresignURL_delete() { ctx := context.Background() diff --git a/simplestorage/presign_options_test.go b/simplestorage/presign_options_test.go new file mode 100644 index 0000000..9935d67 --- /dev/null +++ b/simplestorage/presign_options_test.go @@ -0,0 +1,79 @@ +package simplestorage + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + storage "github.com/tigrisdata/storage-go" + "github.com/tigrisdata/storage-go/tigrisheaders" +) + +func offlinePresignClient(t *testing.T) *Client { + t.Helper() + + s3cli := s3.New(s3.Options{ + Region: "auto", + BaseEndpoint: aws.String("https://t3.storage.dev"), + Credentials: credentials.NewStaticCredentialsProvider("tid_test", "tsec_test", ""), + }) + + return &Client{cli: &storage.Client{Client: s3cli}, options: Options{BucketName: "presign-test"}} +} + +func signedHeaders(t *testing.T, rawURL string) []string { + t.Helper() + + u, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("parse presigned url: %v", err) + } + + return strings.Split(u.Query().Get("X-Amz-SignedHeaders"), ";") +} + +func TestPresignURLSignsS3OptionHeaders(t *testing.T) { + cli := offlinePresignClient(t) + + for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} { + t.Run(method, func(t *testing.T) { + u, err := cli.PresignURL(context.Background(), method, "k", time.Minute, + WithS3Options(tigrisheaders.WithStaticReplicationRegions([]tigrisheaders.Region{tigrisheaders.IAD, tigrisheaders.FRA}))) + if err != nil { + t.Fatalf("PresignURL(%s): %v", method, err) + } + + got := signedHeaders(t, u) + for _, want := range []string{"host", "x-tigris-regions"} { + found := false + for _, h := range got { + if h == want { + found = true + } + } + if !found { + t.Errorf("PresignURL(%s): signed headers %v do not include %q", method, got, want) + } + } + }) + } +} + +func TestPresignURLWithoutS3OptionsSignsHostOnly(t *testing.T) { + cli := offlinePresignClient(t) + + u, err := cli.PresignURL(context.Background(), http.MethodPut, "k", time.Minute) + if err != nil { + t.Fatalf("PresignURL: %v", err) + } + + if got := signedHeaders(t, u); len(got) != 1 || got[0] != "host" { + t.Errorf("signed headers = %v, want [host]", got) + } +}