diff --git a/README.md b/README.md index 6c4853e..b6cf67e 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,14 @@ err := s3.FileDelete(simples3.DeleteInput{ ObjectKey: "test.txt", }) +// You can also download the file. +file, _ := s3.FileDownload(simples3.DownloadInput{ + Bucket: os.Getenv("AWS_S3_BUCKET"), + ObjectKey: "test.txt", +}) +data, _ := ioutil.ReadAll(file) +file.Close() + // You can also use this library to generate // Presigned URLs that can for eg. be used to // GET/PUT files on S3 through the browser. diff --git a/simples3.go b/simples3.go index 2774f0d..eed5eec 100644 --- a/simples3.go +++ b/simples3.go @@ -34,6 +34,12 @@ type S3 struct { URIFormat string } +// DownloadInput is passed to FileUpload as a parameter. +type DownloadInput struct { + Bucket string + ObjectKey string +} + // UploadInput is passed to FileUpload as a parameter. type UploadInput struct { Bucket string @@ -193,6 +199,72 @@ func (s3 *S3) SetClient(client *http.Client) *S3 { return s3 } +func (s3 *S3) signRequest(req *http.Request) error { + var ( + err error + + date = req.Header.Get("Date") + t = time.Now().UTC() + ) + + if date != "" { + t, err = time.Parse(http.TimeFormat, date) + if err != nil { + return err + } + } + req.Header.Set("Date", t.Format(amzDateISO8601TimeFormat)) + + // The x-amz-content-sha256 header is required for all AWS + // Signature Version 4 requests. It provides a hash of the + // request payload. If there is no payload, you must provide + // the hash of an empty string. + emptyhash := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + req.Header.Set("x-amz-content-sha256", emptyhash) + + k := s3.signKeys(t) + h := hmac.New(sha256.New, k) + + s3.writeStringToSign(h, t, req) + + auth := bytes.NewBufferString(algorithm) + auth.Write([]byte(" Credential=" + s3.AccessKey + "/" + s3.creds(t))) + auth.Write([]byte{',', ' '}) + auth.Write([]byte("SignedHeaders=")) + writeHeaderList(auth, req) + auth.Write([]byte{',', ' '}) + auth.Write([]byte("Signature=" + fmt.Sprintf("%x", h.Sum(nil)))) + + req.Header.Set("Authorization", auth.String()) + return nil +} + +// FileDownload makes a GET call and returns a io.ReadCloser. +// After reading the response body, ensure closing the response. +func (s3 *S3) FileDownload(u DownloadInput) (io.ReadCloser, error) { + req, err := http.NewRequest( + http.MethodGet, s3.getURL(u.Bucket, u.ObjectKey), nil, + ) + if err != nil { + return nil, err + } + + if err := s3.signRequest(req); err != nil { + return nil, err + } + + res, err := s3.getClient().Do(req) + if err != nil { + return nil, err + } + + if res.StatusCode != 200 { + return nil, fmt.Errorf("status code: %s", res.Status) + } + + return res.Body, nil +} + // FileUpload makes a POST call with the file written as multipart // and on successful upload, checks for 200 OK. func (s3 *S3) FileUpload(u UploadInput) (UploadResponse, error) { @@ -239,7 +311,7 @@ func (s3 *S3) FileUpload(u UploadInput) (UploadResponse, error) { } // Now that you have a form, you can submit it to your handler. - req, err := http.NewRequest("POST", policies.URL, &b) + req, err := http.NewRequest(http.MethodPost, policies.URL, &b) if err != nil { return UploadResponse{}, err } @@ -272,43 +344,15 @@ func (s3 *S3) FileUpload(u UploadInput) (UploadResponse, error) { // and on successful upload, checks for 204 No Content. func (s3 *S3) FileDelete(u DeleteInput) error { req, err := http.NewRequest( - "DELETE", s3.getURL(u.Bucket, u.ObjectKey), nil, + http.MethodDelete, s3.getURL(u.Bucket, u.ObjectKey), nil, ) if err != nil { return err } - date := req.Header.Get("Date") - t := time.Now().UTC() - if date != "" { - t, err = time.Parse(http.TimeFormat, date) - if err != nil { - return err - } + if err := s3.signRequest(req); err != nil { + return err } - req.Header.Set("Date", t.Format(amzDateISO8601TimeFormat)) - - // The x-amz-content-sha256 header is required for all AWS - // Signature Version 4 requests. It provides a hash of the - // request payload. If there is no payload, you must provide - // the hash of an empty string. - emptyhash := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - req.Header.Set("x-amz-content-sha256", emptyhash) - - k := s3.signKeys(t) - h := hmac.New(sha256.New, k) - - s3.writeStringToSign(h, t, req) - - auth := bytes.NewBufferString(algorithm) - auth.Write([]byte(" Credential=" + s3.AccessKey + "/" + s3.creds(t))) - auth.Write([]byte{',', ' '}) - auth.Write([]byte("SignedHeaders=")) - writeHeaderList(auth, req) - auth.Write([]byte{',', ' '}) - auth.Write([]byte("Signature=" + fmt.Sprintf("%x", h.Sum(nil)))) - - req.Header.Set("Authorization", auth.String()) // Submit the request client := s3.getClient() diff --git a/simples3_test.go b/simples3_test.go index 9d2976d..9dcd495 100644 --- a/simples3_test.go +++ b/simples3_test.go @@ -1,7 +1,9 @@ package simples3 import ( + "bytes" "io" + "io/ioutil" "net/http" "net/http/httptest" "os" @@ -92,6 +94,97 @@ func TestS3_FileUpload(t *testing.T) { } } +func TestS3_FileDownload(t *testing.T) { + testTxt, err := os.Open("testdata/test.txt") + if err != nil { + t.Fatal(err) + } + defer testTxt.Close() + testTxtData, err := ioutil.ReadAll(testTxt) + if err != nil { + t.Fatal(err) + } + + testPng, err := os.Open("testdata/avatar.png") + if err != nil { + t.Fatal(err) + } + defer testPng.Close() + testPngData, err := ioutil.ReadAll(testPng) + if err != nil { + t.Fatal(err) + } + + type args struct { + u DownloadInput + } + tests := []struct { + name string + fields tConfig + args args + wantErr bool + wantResponse []byte + }{ + { + name: "txt", + fields: tConfig{ + AccessKey: os.Getenv("AWS_S3_ACCESS_KEY"), + SecretKey: os.Getenv("AWS_S3_SECRET_KEY"), + Endpoint: os.Getenv("AWS_S3_ENDPOINT"), + Region: os.Getenv("AWS_S3_REGION"), + }, + args: args{ + u: DownloadInput{ + Bucket: os.Getenv("AWS_S3_BUCKET"), + ObjectKey: "test.txt", + }, + }, + wantErr: false, + wantResponse: testTxtData, + }, + { + name: "png", + fields: tConfig{ + AccessKey: os.Getenv("AWS_S3_ACCESS_KEY"), + SecretKey: os.Getenv("AWS_S3_SECRET_KEY"), + Endpoint: os.Getenv("AWS_S3_ENDPOINT"), + Region: os.Getenv("AWS_S3_REGION"), + }, + args: args{ + u: DownloadInput{ + Bucket: os.Getenv("AWS_S3_BUCKET"), + ObjectKey: "xyz/image.png", + }, + }, + wantErr: false, + wantResponse: testPngData, + }, + } + + for _, testcase := range tests { + tt := testcase + t.Run(tt.name, func(t *testing.T) { + s3 := New(tt.fields.Region, tt.fields.AccessKey, tt.fields.SecretKey) + s3.SetEndpoint(tt.fields.Endpoint) + resp, err := s3.FileDownload(tt.args.u) + if (err != nil) != tt.wantErr { + t.Fatalf("S3.FileDownload() error = %v, wantErr %v", err, tt.wantErr) + } + + got, err := ioutil.ReadAll(resp) + if err != nil { + t.Fatalf("error = %v", err) + } + + resp.Close() + + if !bytes.Equal(got, tt.wantResponse) { + t.Fatalf("S3.FileDownload() = %v, want %v", got, tt.wantResponse) + } + }) + } +} + func TestS3_FileDelete(t *testing.T) { type args struct { u DeleteInput