diff --git a/.cfignore b/.cfignore index 7cc372f..3c9b64f 100644 --- a/.cfignore +++ b/.cfignore @@ -1,3 +1,5 @@ -vendor -s3-manager -README.md +* + +!/templates +!/entrypoint-cf.sh +!/s3manager diff --git a/.gitignore b/.gitignore index 1c555b4..a145212 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -vendor -s3-manager +/s3manager diff --git a/Dockerfile b/Dockerfile index 21beca0..926a794 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,10 @@ -FROM golang:onbuild +FROM golang + +ADD . /go/src/github.com/mastertinner/s3manager +WORKDIR /go/src/github.com/mastertinner/s3manager + +RUN go build ./cmd/s3manager EXPOSE 8080 + +CMD ./s3manager -endpoint "${S3_ENDPOINT}" -access-key-id "${S3_ACCESS_KEY_ID}" -secret-access-key "${S3_SECRET_ACCESS_KEY}" diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..7c225bc --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,43 @@ +memo = "2564b0b342a1586a24a3ff5356636c822f6351da32f8ee9497ab8694855e80b1" + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + name = "github.com/gorilla/context" + packages = ["."] + revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a" + version = "v1.1" + +[[projects]] + name = "github.com/gorilla/mux" + packages = ["."] + revision = "392c28fe23e1c45ddba891b0320b3b5df220beea" + version = "v1.3.0" + +[[projects]] + branch = "master" + name = "github.com/mastertinner/adapters" + packages = [".","logging"] + revision = "3bfe6170b9beca289d89e4c703c7f9db68e7c158" + +[[projects]] + name = "github.com/minio/minio-go" + packages = [".","pkg/encrypt","pkg/policy","pkg/s3signer","pkg/s3utils","pkg/set"] + revision = "2f03abaa07d8bc57faef16cda7655ea62a7e0bed" + version = "v2.1.0" + +[[projects]] + name = "github.com/pmezard/go-difflib" + packages = ["difflib"] + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/stretchr/testify" + packages = ["assert"] + revision = "4d4bfba8f1d1027c4fdbe371823030df51419987" diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..7d72c8f --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,76 @@ + +## Gopkg.toml example (these lines may be deleted) + +## "required" lists a set of packages (not projects) that must be included in +## Gopkg.lock. This list is merged with the set of packages imported by the current +## project. Use it when your project needs a package it doesn't explicitly import - +## including "main" packages. +# required = ["github.com/user/thing/cmd/thing"] + +## "ignored" lists a set of packages (not projects) that are ignored when +## dep statically analyzes source code. Ignored packages can be in this project, +## or in a dependency. +# ignored = ["github.com/user/project/badpkg"] + +## Dependencies define constraints on dependent projects. They are respected by +## dep whether coming from the Gopkg.toml of the current project or a dependency. +# [[dependencies]] +## Required: the root import path of the project being constrained. +# name = "github.com/user/project" +# +## Recommended: the version constraint to enforce for the project. +## Only one of "branch", "version" or "revision" can be specified. +# version = "1.0.0" +# branch = "master" +# revision = "abc123" +# +## Optional: an alternate location (URL or import path) for the project's source. +# source = "https://github.com/myfork/package.git" + +## Overrides have the same structure as [[dependencies]], but supercede all +## [[dependencies]] declarations from all projects. Only the current project's +## [[overrides]] are applied. +## +## Overrides are a sledgehammer. Use them only as a last resort. +# [[overrides]] +## Required: the root import path of the project being constrained. +# name = "github.com/user/project" +# +## Optional: specifying a version constraint override will cause all other +## constraints on this project to be ignored; only the overriden constraint +## need be satisfied. +## Again, only one of "branch", "version" or "revision" can be specified. +# version = "1.0.0" +# branch = "master" +# revision = "abc123" +# +## Optional: specifying an alternate source location as an override will +## enforce that the alternate location is used for that project, regardless of +## what source location any dependent projects specify. +# source = "https://github.com/myfork/package.git" + + + +[[dependencies]] + name = "github.com/davecgh/go-spew" + version = "^1.1.0" + +[[dependencies]] + name = "github.com/gorilla/mux" + version = "^1.3.0" + +[[dependencies]] + branch = "master" + name = "github.com/mastertinner/adapters" + +[[dependencies]] + name = "github.com/minio/minio-go" + version = "^2.1.0" + +[[dependencies]] + name = "github.com/pmezard/go-difflib" + version = "^1.0.0" + +[[dependencies]] + branch = "master" + name = "github.com/stretchr/testify" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6ab0ed4 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +all: + go build ./cmd/s3manager + +test: + go test + +build-docker: + docker run --rm -v "${PWD}:/go/src/github.com/mastertinner/s3manager" -w /go/src/github.com/mastertinner/s3manager golang go build ./cmd/s3manager + +deploy-cf: + GOOS=linux GOARCH=amd64 go build ./cmd/s3manager + cf push diff --git a/README.md b/README.md index db8564c..3e08a85 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,13 @@ # S3 Manager -[![Build Status](https://travis-ci.org/mastertinner/s3-manager.svg?branch=master)](https://travis-ci.org/mastertinner/s3-manager) +[![Build Status](https://travis-ci.org/mastertinner/s3manager.svg?branch=master)](https://travis-ci.org/mastertinner/s3manager) A Web GUI written in Go to manage S3 buckets from any provider. -## Environment Variables - -* `S3_ACCESS_KEY_ID`: Required. Your S3 access key ID -* `S3_SECRET_ACCESS_KEY`: Required. Your S3 secret access key -* `S3_ENDPOINT`: Optional. In case you are using a different S3 provider than AWS. Defaults to `s3.amazonaws.com` -* `V2_SIGNING`: Optional. In case your S3 provider still uses V2 Signing, set this to `true` - ## Run locally -1. Run `go build` -1. Set environment variables in your env -1. Execute the binary and visit +1. Run `make` +1. Execute the created binary and visit ## Run with Docker @@ -25,14 +17,14 @@ A Web GUI written in Go to manage S3 buckets from any provider. ## Build with Docker and run anywhere -1. Run `docker run --rm -v "${PWD}:/go/src/github.com/mastertinner/s3-manager" -w /go/src/github.com/mastertinner/s3-manager golang curl https://glide.sh/get | sh && glide install && go build` +1. Run `make build-docker` - To cross-compile for windows, use the `-e "GOOS=windows" -e "GOARCH=amd64"` flags (depending on your system, you might have to adjust `GOARCH`) + To cross-compile for windows, add the `-e "GOOS=windows" -e "GOARCH=amd64"` flags to the `Makefile` (depending on your system, you might have to adjust `GOARCH`) - To cross-compile for macOS, use the `-e "GOOS=darwin" -e "GOARCH=amd64"` flags (depending on your system, you might have to adjust `GOARCH`) + To cross-compile for macOS, add the `-e "GOOS=darwin" -e "GOARCH=amd64"` flags to the `Makefile` (depending on your system, you might have to adjust `GOARCH`) ## Run on Cloud Foundry -1. Set environment variables in `manifest.yml` -1. Set host that isn't taken yet in `manifest.yml` -1. Run `cf push` +1. Change the service in `manifest.yml` to represent your S3 service (if you are using an external S3 provider, you'll have to switch the service type in `entrypoint-cf.sh` from `dynstrg` to `user-provided` and create the respective user-provided service with `cf create-user-provided-service`) +1. Change `host` in `manifest.yml` to something that isn't taken yet +1. Run `make deploy-cf` diff --git a/bucket-view.go b/bucket-view.go index 6e4e5cc..2877c73 100644 --- a/bucket-view.go +++ b/bucket-view.go @@ -1,4 +1,4 @@ -package main +package s3manager import ( "html/template" @@ -9,23 +9,23 @@ import ( minio "github.com/minio/minio-go" ) -// ObjectWithIcon is a minio object with an added icon -type ObjectWithIcon struct { +// objectWithIcon is a minio object with an added icon. +type objectWithIcon struct { minio.ObjectInfo Icon string } -// BucketPage defines the details page of a bucket -type BucketPage struct { +// bucketPage defines the details page of a bucket. +type bucketPage struct { BucketName string - Objects []ObjectWithIcon + Objects []objectWithIcon } -// BucketViewHandler shows the details page of a bucket -func BucketViewHandler(s3 S3Client) http.Handler { +// BucketViewHandler shows the details page of a bucket. +func BucketViewHandler(s3 S3) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { bucketName := mux.Vars(r)["bucketName"] - var objs []ObjectWithIcon + var objs []objectWithIcon l := path.Join(tmplDirectory, "layout.html.tmpl") p := path.Join(tmplDirectory, "bucket.html.tmpl") @@ -49,16 +49,16 @@ func BucketViewHandler(s3 S3Client) http.Handler { handleHTTPError(w, code, object.Err) return } - objectWithIcon := ObjectWithIcon{object, icon(object.Key)} - objs = append(objs, objectWithIcon) + obj := objectWithIcon{object, icon(object.Key)} + objs = append(objs, obj) } - bucketPage := BucketPage{ + page := bucketPage{ BucketName: bucketName, Objects: objs, } - err = t.ExecuteTemplate(w, "layout", bucketPage) + err = t.ExecuteTemplate(w, "layout", page) if err != nil { handleHTTPError(w, http.StatusInternalServerError, err) return @@ -66,7 +66,7 @@ func BucketViewHandler(s3 S3Client) http.Handler { }) } -// icon returns an icon for a file type +// icon returns an icon for a file type. func icon(fileName string) string { e := path.Ext(fileName) diff --git a/bucket-view_test.go b/bucket-view_test.go index 2a63196..5d0a396 100644 --- a/bucket-view_test.go +++ b/bucket-view_test.go @@ -1,4 +1,4 @@ -package main +package s3manager_test import ( "errors" @@ -9,6 +9,7 @@ import ( "testing" "github.com/gorilla/mux" + "github.com/mastertinner/s3manager" minio "github.com/minio/minio-go" "github.com/stretchr/testify/assert" ) @@ -16,14 +17,14 @@ import ( func TestBucketViewHandler(t *testing.T) { assert := assert.New(t) - tests := map[string]struct { - s3 S3Client + cases := map[string]struct { + s3 s3manager.S3 bucketName string expectedStatusCode int expectedBodyContains string }{ "success (empty bucket)": { - s3: &S3ClientMock{ + s3: &s3Mock{ Buckets: []minio.BucketInfo{ {Name: "testBucket"}, }, @@ -33,7 +34,7 @@ func TestBucketViewHandler(t *testing.T) { expectedBodyContains: "No objects in", }, "success (with file)": { - s3: &S3ClientMock{ + s3: &s3Mock{ Buckets: []minio.BucketInfo{ {Name: "testBucket"}, }, @@ -46,7 +47,7 @@ func TestBucketViewHandler(t *testing.T) { expectedBodyContains: "testBucket", }, "success (archive)": { - s3: &S3ClientMock{ + s3: &s3Mock{ Buckets: []minio.BucketInfo{ {Name: "testBucket"}, }, @@ -59,7 +60,7 @@ func TestBucketViewHandler(t *testing.T) { expectedBodyContains: "archive", }, "success (image)": { - s3: &S3ClientMock{ + s3: &s3Mock{ Buckets: []minio.BucketInfo{ {Name: "testBucket"}, }, @@ -72,7 +73,7 @@ func TestBucketViewHandler(t *testing.T) { expectedBodyContains: "photo", }, "success (sound)": { - s3: &S3ClientMock{ + s3: &s3Mock{ Buckets: []minio.BucketInfo{ {Name: "testBucket"}, }, @@ -85,13 +86,13 @@ func TestBucketViewHandler(t *testing.T) { expectedBodyContains: "music_note", }, "bucket doesn't exist": { - s3: &S3ClientMock{}, + s3: &s3Mock{}, bucketName: "testBucket", expectedStatusCode: http.StatusNotFound, expectedBodyContains: http.StatusText(http.StatusNotFound), }, "s3 error": { - s3: &S3ClientMock{ + s3: &s3Mock{ Err: errors.New("mocked S3 error"), }, bucketName: "testBucket", @@ -100,12 +101,12 @@ func TestBucketViewHandler(t *testing.T) { }, } - for tcID, tc := range tests { + for tcID, tc := range cases { r := mux.NewRouter() r. Methods(http.MethodGet). Path("/buckets/{bucketName}"). - Handler(BucketViewHandler(tc.s3)) + Handler(s3manager.BucketViewHandler(tc.s3)) ts := httptest.NewServer(r) defer ts.Close() diff --git a/buckets-view.go b/buckets-view.go index b80833a..77cfdba 100644 --- a/buckets-view.go +++ b/buckets-view.go @@ -1,4 +1,4 @@ -package main +package s3manager import ( "html/template" @@ -6,8 +6,8 @@ import ( "path" ) -// BucketsViewHandler shows all buckets -func BucketsViewHandler(s3 S3Client) http.Handler { +// BucketsViewHandler renders all buckets on an HTML page. +func BucketsViewHandler(s3 S3) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { l := path.Join(tmplDirectory, "layout.html.tmpl") p := path.Join(tmplDirectory, "buckets.html.tmpl") diff --git a/buckets-view_test.go b/buckets-view_test.go index a6c851c..9c9dbcc 100644 --- a/buckets-view_test.go +++ b/buckets-view_test.go @@ -1,4 +1,4 @@ -package main +package s3manager_test import ( "errors" @@ -6,6 +6,7 @@ import ( "net/http/httptest" "testing" + "github.com/mastertinner/s3manager" minio "github.com/minio/minio-go" "github.com/stretchr/testify/assert" ) @@ -13,13 +14,13 @@ import ( func TestBucketsViewHandler(t *testing.T) { assert := assert.New(t) - tests := map[string]struct { - s3 S3Client + cases := map[string]struct { + s3 s3manager.S3 expectedStatusCode int expectedBodyContains string }{ "success": { - s3: &S3ClientMock{ + s3: &s3Mock{ Buckets: []minio.BucketInfo{ {Name: "testBucket"}, }, @@ -28,12 +29,12 @@ func TestBucketsViewHandler(t *testing.T) { expectedBodyContains: "testBucket", }, "success (bo buckets)": { - s3: &S3ClientMock{}, + s3: &s3Mock{}, expectedStatusCode: http.StatusOK, expectedBodyContains: "No buckets yet", }, "s3 error": { - s3: &S3ClientMock{ + s3: &s3Mock{ Err: errors.New("mocked S3 error"), }, expectedStatusCode: http.StatusInternalServerError, @@ -41,12 +42,12 @@ func TestBucketsViewHandler(t *testing.T) { }, } - for tcID, tc := range tests { + for tcID, tc := range cases { req, err := http.NewRequest(http.MethodGet, "/buckets", nil) assert.NoError(err, tcID) rr := httptest.NewRecorder() - handler := BucketsViewHandler(tc.s3) + handler := s3manager.BucketsViewHandler(tc.s3) handler.ServeHTTP(rr, req) diff --git a/cmd/s3manager/main.go b/cmd/s3manager/main.go new file mode 100644 index 0000000..7d755db --- /dev/null +++ b/cmd/s3manager/main.go @@ -0,0 +1,115 @@ +package main + +import ( + "flag" + "log" + "net/http" + "os" + + "github.com/gorilla/mux" + "github.com/mastertinner/adapters" + "github.com/mastertinner/adapters/logging" + "github.com/mastertinner/s3manager" + minio "github.com/minio/minio-go" +) + +func main() { + var ( + port = flag.String("port", "8080", "the port the app should listen on") + endpoint = flag.String("endpoint", "s3.amazonaws.com", "the s3 endpoint to use") + accessKeyID = flag.String("access-key-id", "", "your s3 access key ID") + secretAccessKey = flag.String("secret-access-key", "", "your s3 secret access key") + v2Signing = flag.Bool("v2-signing", false, "set this flag if your S3 provider still uses V2 signing") + ) + flag.Parse() + + if *accessKeyID == "" || *secretAccessKey == "" { + flag.Usage() + os.Exit(2) + } + + // Set up S3 client + var s3 *minio.Client + var err error + if *v2Signing { + s3, err = minio.NewV2(*endpoint, *accessKeyID, *secretAccessKey, true) + } else { + s3, err = minio.New(*endpoint, *accessKeyID, *secretAccessKey, true) + } + if err != nil { + log.Fatalln(err) + } + + // Set up logger + logger := log.New(os.Stdout, "", log.Ldate|log.Ltime) + + // Set up router + r := mux.NewRouter().StrictSlash(true) + r. + Methods(http.MethodGet). + Path("/"). + Handler(adapters.Adapt( + http.RedirectHandler("/buckets", http.StatusPermanentRedirect), + logging.Handler(logger), + )) + r. + Methods(http.MethodGet). + Path("/buckets"). + Handler(adapters.Adapt( + s3manager.BucketsViewHandler(s3), + logging.Handler(logger), + )) + r. + Methods(http.MethodGet). + Path("/buckets/{bucketName}"). + Handler(adapters.Adapt( + s3manager.BucketViewHandler(s3), + logging.Handler(logger), + )) + r. + Methods(http.MethodPost). + Path("/api/buckets"). + Handler(adapters.Adapt( + s3manager.CreateBucketHandler(s3), + logging.Handler(logger), + )) + r. + Methods(http.MethodDelete). + Path("/api/buckets/{bucketName}"). + Handler(adapters.Adapt( + s3manager.DeleteBucketHandler(s3), + logging.Handler(logger), + )) + r. + Methods(http.MethodPost). + Headers(s3manager.HeaderContentType, s3manager.ContentTypeJSON). + Path("/api/buckets/{bucketName}/objects"). + Handler(adapters.Adapt( + s3manager.CopyObjectHandler(s3), + logging.Handler(logger), + )) + r. + Methods(http.MethodPost). + HeadersRegexp(s3manager.HeaderContentType, s3manager.ContentTypeMultipartForm). + Path("/api/buckets/{bucketName}/objects"). + Handler(adapters.Adapt( + s3manager.CreateObjectHandler(s3), + logging.Handler(logger), + )) + r. + Methods(http.MethodGet). + Path("/api/buckets/{bucketName}/objects/{objectName}"). + Handler(adapters.Adapt( + s3manager.GetObjectHandler(s3), + logging.Handler(logger), + )) + r. + Methods(http.MethodDelete). + Path("/api/buckets/{bucketName}/objects/{objectName}"). + Handler(adapters.Adapt( + s3manager.DeleteObjectHandler(s3), + logging.Handler(logger), + )) + + log.Fatal(http.ListenAndServe(":"+*port, r)) +} diff --git a/copy-object.go b/copy-object.go index 7ed3ad2..a2f564b 100644 --- a/copy-object.go +++ b/copy-object.go @@ -1,4 +1,4 @@ -package main +package s3manager import ( "encoding/json" @@ -8,18 +8,18 @@ import ( minio "github.com/minio/minio-go" ) -// CopyObjectInfo is the information about an object to copy -type CopyObjectInfo struct { +// copyObjectInfo is the information about an object to copy. +type copyObjectInfo struct { BucketName string `json:"bucketName"` ObjectName string `json:"objectName"` SourceBucketName string `json:"sourceBucketName"` SourceObjectName string `json:"sourceObjectName"` } -// CopyObjectHandler allows to copy an existing object -func CopyObjectHandler(s3 S3Client) http.Handler { +// CopyObjectHandler copies an existing object under a new name. +func CopyObjectHandler(s3 S3) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var copy CopyObjectInfo + var copy copyObjectInfo err := json.NewDecoder(r.Body).Decode(©) if err != nil { @@ -35,7 +35,7 @@ func CopyObjectHandler(s3 S3Client) http.Handler { return } - w.Header().Set(headerContentType, contentTypeJSON) + w.Header().Set(HeaderContentType, ContentTypeJSON) w.WriteHeader(http.StatusCreated) err = json.NewEncoder(w).Encode(copy) diff --git a/create-bucket.go b/create-bucket.go index 6eeaf24..7097a9e 100644 --- a/create-bucket.go +++ b/create-bucket.go @@ -1,4 +1,4 @@ -package main +package s3manager import ( "encoding/json" @@ -7,8 +7,8 @@ import ( minio "github.com/minio/minio-go" ) -// CreateBucketHandler creates a new bucket -func CreateBucketHandler(s3 S3Client) http.Handler { +// CreateBucketHandler creates a new bucket. +func CreateBucketHandler(s3 S3) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var bucket minio.BucketInfo @@ -24,7 +24,7 @@ func CreateBucketHandler(s3 S3Client) http.Handler { return } - w.Header().Set(headerContentType, contentTypeJSON) + w.Header().Set(HeaderContentType, ContentTypeJSON) w.WriteHeader(http.StatusCreated) err = json.NewEncoder(w).Encode(bucket) diff --git a/create-bucket_test.go b/create-bucket_test.go index f6f4351..5b280ba 100644 --- a/create-bucket_test.go +++ b/create-bucket_test.go @@ -1,4 +1,4 @@ -package main +package s3manager_test import ( "bytes" @@ -7,38 +7,39 @@ import ( "net/http/httptest" "testing" + "github.com/mastertinner/s3manager" "github.com/stretchr/testify/assert" ) func TestCreateBucketHandler(t *testing.T) { assert := assert.New(t) - tests := map[string]struct { - s3 S3Client + cases := map[string]struct { + s3 s3manager.S3 body string expectedStatusCode int expectedBodyContains string }{ "success": { - s3: &S3ClientMock{}, + s3: &s3Mock{}, body: "{\"name\":\"myBucket\"}", expectedStatusCode: http.StatusCreated, expectedBodyContains: "{\"name\":\"myBucket\",\"creationDate\":\"0001-01-01T00:00:00Z\"}\n", }, "empty request": { - s3: &S3ClientMock{}, + s3: &s3Mock{}, body: "", expectedStatusCode: http.StatusUnprocessableEntity, expectedBodyContains: http.StatusText(http.StatusUnprocessableEntity), }, "malformed request": { - s3: &S3ClientMock{}, + s3: &s3Mock{}, body: "}", expectedStatusCode: http.StatusUnprocessableEntity, expectedBodyContains: http.StatusText(http.StatusUnprocessableEntity), }, "s3 error": { - s3: &S3ClientMock{ + s3: &s3Mock{ Err: errors.New("mocked S3 error"), }, body: "{\"name\":\"myBucket\"}", @@ -47,12 +48,12 @@ func TestCreateBucketHandler(t *testing.T) { }, } - for tcID, tc := range tests { + for tcID, tc := range cases { req, err := http.NewRequest(http.MethodPost, "/api/buckets", bytes.NewBufferString(tc.body)) assert.NoError(err, tcID) rr := httptest.NewRecorder() - handler := CreateBucketHandler(tc.s3) + handler := s3manager.CreateBucketHandler(tc.s3) handler.ServeHTTP(rr, req) diff --git a/create-object.go b/create-object.go index 9ea85f3..9bc8824 100644 --- a/create-object.go +++ b/create-object.go @@ -1,4 +1,4 @@ -package main +package s3manager import ( "log" @@ -7,8 +7,8 @@ import ( "github.com/gorilla/mux" ) -// CreateObjectHandler allows to upload a new object -func CreateObjectHandler(s3 S3Client) http.Handler { +// CreateObjectHandler uploads a new object. +func CreateObjectHandler(s3 S3) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { err := r.ParseMultipartForm(32 << 20) if err != nil { diff --git a/delete-bucket.go b/delete-bucket.go index caae0d4..1e471c2 100644 --- a/delete-bucket.go +++ b/delete-bucket.go @@ -1,4 +1,4 @@ -package main +package s3manager import ( "net/http" @@ -6,8 +6,8 @@ import ( "github.com/gorilla/mux" ) -// DeleteBucketHandler deletes a bucket -func DeleteBucketHandler(s3 S3Client) http.Handler { +// DeleteBucketHandler deletes a bucket. +func DeleteBucketHandler(s3 S3) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { bucketName := mux.Vars(r)["bucketName"] err := s3.RemoveBucket(bucketName) diff --git a/delete-bucket_test.go b/delete-bucket_test.go index c6b091c..a4d806d 100644 --- a/delete-bucket_test.go +++ b/delete-bucket_test.go @@ -1,4 +1,4 @@ -package main +package s3manager_test import ( "errors" @@ -6,24 +6,25 @@ import ( "net/http/httptest" "testing" + "github.com/mastertinner/s3manager" "github.com/stretchr/testify/assert" ) func TestDeleteBucketHandler(t *testing.T) { assert := assert.New(t) - tests := map[string]struct { - s3 S3Client + cases := map[string]struct { + s3 s3manager.S3 expectedStatusCode int expectedBodyContains string }{ "success": { - s3: &S3ClientMock{}, + s3: &s3Mock{}, expectedStatusCode: http.StatusNoContent, expectedBodyContains: "", }, "s3 error": { - s3: &S3ClientMock{ + s3: &s3Mock{ Err: errors.New("mocked S3 error"), }, expectedStatusCode: http.StatusInternalServerError, @@ -31,12 +32,12 @@ func TestDeleteBucketHandler(t *testing.T) { }, } - for tcID, tc := range tests { + for tcID, tc := range cases { req, err := http.NewRequest(http.MethodDelete, "/api/buckets/bucketName", nil) assert.NoError(err, tcID) rr := httptest.NewRecorder() - handler := DeleteBucketHandler(tc.s3) + handler := s3manager.DeleteBucketHandler(tc.s3) handler.ServeHTTP(rr, req) diff --git a/delete-object.go b/delete-object.go index 6901235..f8ca8ad 100644 --- a/delete-object.go +++ b/delete-object.go @@ -1,4 +1,4 @@ -package main +package s3manager import ( "net/http" @@ -6,8 +6,8 @@ import ( "github.com/gorilla/mux" ) -// DeleteObjectHandler deletes an object -func DeleteObjectHandler(s3 S3Client) http.Handler { +// DeleteObjectHandler deletes an object. +func DeleteObjectHandler(s3 S3) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) bucketName := vars["bucketName"] diff --git a/delete-object_test.go b/delete-object_test.go index 24cd7b4..ee528fa 100644 --- a/delete-object_test.go +++ b/delete-object_test.go @@ -1,4 +1,4 @@ -package main +package s3manager_test import ( "errors" @@ -6,24 +6,25 @@ import ( "net/http/httptest" "testing" + "github.com/mastertinner/s3manager" "github.com/stretchr/testify/assert" ) func TestDeleteObjectHandler(t *testing.T) { assert := assert.New(t) - tests := map[string]struct { - s3 S3Client + cases := map[string]struct { + s3 s3manager.S3 expectedStatusCode int expectedBodyContains string }{ "success": { - s3: &S3ClientMock{}, + s3: &s3Mock{}, expectedStatusCode: http.StatusNoContent, expectedBodyContains: "", }, "s3 error": { - s3: &S3ClientMock{ + s3: &s3Mock{ Err: errors.New("mocked S3 error"), }, expectedStatusCode: http.StatusInternalServerError, @@ -31,12 +32,12 @@ func TestDeleteObjectHandler(t *testing.T) { }, } - for tcID, tc := range tests { + for tcID, tc := range cases { req, err := http.NewRequest(http.MethodDelete, "/api/buckets/bucketName/objects/objectName", nil) assert.NoError(err, tcID) rr := httptest.NewRecorder() - handler := DeleteObjectHandler(tc.s3) + handler := s3manager.DeleteObjectHandler(tc.s3) handler.ServeHTTP(rr, req) diff --git a/docker-compose.yml b/docker-compose.yml index 84f1ab1..176bd80 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,11 @@ version: '2' services: - s3-manager: - build: . - ports: - - "8080:8080" - environment: - - S3_ACCESS_KEY_ID=xxx - - S3_SECRET_ACCESS_KEY=xxx + s3manager: + build: . + ports: + - "8080:8080" + environment: + - S3_ENDPOINT=s3.amazonaws.com + - S3_ACCESS_KEY_ID=xxx + - S3_SECRET_ACCESS_KEY=xxx diff --git a/entrypoint-cf.sh b/entrypoint-cf.sh new file mode 100755 index 0000000..8c81194 --- /dev/null +++ b/entrypoint-cf.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -e -u + +if [ -z "${PORT}" ]; then + echo "Error: No PORT found" >&2 + exit 1 +fi +if [ -z "${VCAP_SERVICES}" ]; then + echo "Error: No VCAP_SERVICES found" >&2 + exit 1 +fi + +# S3 +s3_credentials="$(echo "${VCAP_SERVICES}" | jq -r '.["dynstrg"][0].credentials // ""')" +if [ -z "${s3_credentials}" ]; then + echo "Error: Please bind an S3 service" >&2 + exit 1 +fi +s3_endpoint="$(echo "${s3_credentials}" | jq -r '.accessHost // ""')" +s3_access_key_id="$(echo "${s3_credentials}" | jq -r '.accessKey // ""')" +s3_secret_access_key="$(echo "${s3_credentials}" | jq -r '.sharedSecret // ""')" + +# Run binary +./s3manager \ + -port "${PORT}" \ + -endpoint "${s3_endpoint}" \ + -access-key-id "${s3_access_key_id}" \ + -secret-access-key "${s3_secret_access_key}" \ + -v2-signing diff --git a/errors.go b/errors.go index 0c3c3c5..5f40cc5 100644 --- a/errors.go +++ b/errors.go @@ -1,17 +1,17 @@ -package main +package s3manager import ( "log" "net/http" ) -// Common error messages within the app +// Error codes that may be returned from an S3 backend. const ( ErrBucketDoesNotExist = "The specified bucket does not exist." ErrKeyDoesNotExist = "The specified key does not exist." ) -// handleHTTPError handles HTTP errors +// handleHTTPError handles HTTP errors. func handleHTTPError(w http.ResponseWriter, statusCode int, err error) { msg := http.StatusText(statusCode) http.Error(w, msg, statusCode) diff --git a/get-object.go b/get-object.go index aded2b8..7f47fa2 100644 --- a/get-object.go +++ b/get-object.go @@ -1,4 +1,4 @@ -package main +package s3manager import ( "fmt" @@ -8,8 +8,8 @@ import ( "github.com/gorilla/mux" ) -// GetObjectHandler downloads an object to the client -func GetObjectHandler(s3 S3Client) http.Handler { +// GetObjectHandler downloads an object to the client. +func GetObjectHandler(s3 S3) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) bucketName := vars["bucketName"] @@ -22,7 +22,7 @@ func GetObjectHandler(s3 S3Client) http.Handler { } w.Header().Set(headerContentDisposition, fmt.Sprintf("attachment; filename=\"%s\"", objectName)) - w.Header().Set(headerContentType, contentTypeOctetStream) + w.Header().Set(HeaderContentType, contentTypeOctetStream) _, err = io.Copy(w, object) if err != nil { diff --git a/get-object_test.go b/get-object_test.go index f928bc5..39ca891 100644 --- a/get-object_test.go +++ b/get-object_test.go @@ -1,4 +1,4 @@ -package main +package s3manager_test import ( "errors" @@ -9,21 +9,22 @@ import ( "testing" "github.com/gorilla/mux" + "github.com/mastertinner/s3manager" "github.com/stretchr/testify/assert" ) func TestGetObjectHandler(t *testing.T) { assert := assert.New(t) - tests := map[string]struct { - s3 S3Client + cases := map[string]struct { + s3 s3manager.S3 bucketName string objectName string expectedStatusCode int expectedBodyContains string }{ "s3 error": { - s3: &S3ClientMock{ + s3: &s3Mock{ Err: errors.New("mocked S3 error"), }, bucketName: "testBucket", @@ -33,12 +34,12 @@ func TestGetObjectHandler(t *testing.T) { }, } - for tcID, tc := range tests { + for tcID, tc := range cases { r := mux.NewRouter() r. Methods(http.MethodGet). Path("/buckets/{bucketName}/objects/{objectName}"). - Handler(GetObjectHandler(tc.s3)) + Handler(s3manager.GetObjectHandler(tc.s3)) ts := httptest.NewServer(r) defer ts.Close() diff --git a/glide.lock b/glide.lock deleted file mode 100644 index 10a7589..0000000 --- a/glide.lock +++ /dev/null @@ -1,31 +0,0 @@ -hash: 9b88c8ce183463a407bdb87f79b6b857d8343a7bb5ce4723223c539c654d6d6d -updated: 2017-05-03T10:21:06.902434164+02:00 -imports: -- name: github.com/gorilla/context - version: 1ea25387ff6f684839d82767c1733ff4d4d15d0a -- name: github.com/gorilla/mux - version: 392c28fe23e1c45ddba891b0320b3b5df220beea -- name: github.com/mastertinner/adapters - version: b1cb3d82590975623f722e92951658939fdfc328 - subpackages: - - logging -- name: github.com/minio/minio-go - version: dcaae9ec4d0b0a81d17f22f6d7a186491f6a55ec - subpackages: - - pkg/policy - - pkg/s3signer - - pkg/s3utils - - pkg/set -- name: github.com/stretchr/testify - version: 69483b4bd14f5845b5a1e55bca19e954e827f1d0 - subpackages: - - assert -testImports: -- name: github.com/davecgh/go-spew - version: 6d212800a42e8ab5c146b8ace3490ee17e5225f9 - subpackages: - - spew -- name: github.com/pmezard/go-difflib - version: d8ed2627bdf02c080bf22230dbb337003b7aba2d - subpackages: - - difflib diff --git a/glide.yaml b/glide.yaml deleted file mode 100644 index 72842e2..0000000 --- a/glide.yaml +++ /dev/null @@ -1,13 +0,0 @@ -package: github.com/mastertinner/s3-manager -import: -- package: github.com/minio/minio-go - version: ^2.0.2 -- package: github.com/gorilla/mux - version: ^1.1.0 -- package: github.com/stretchr/testify - version: ^1.1.4 - subpackages: - - assert -- package: github.com/mastertinner/adapters - subpackages: - - logging diff --git a/main.go b/main.go deleted file mode 100644 index e1ab77b..0000000 --- a/main.go +++ /dev/null @@ -1,106 +0,0 @@ -package main - -import ( - "log" - "net/http" - "os" - - "github.com/gorilla/mux" - "github.com/mastertinner/adapters" - "github.com/mastertinner/adapters/logging" -) - -const ( - tmplDirectory = "templates" - headerContentType = "Content-Type" - headerContentDisposition = "Content-Disposition" - contentTypeJSON = "application/json" - contentTypeMultipartForm = "multipart/form-data" - contentTypeOctetStream = "application/octet-stream" -) - -func main() { - s3, err := newMinioClient() - if err != nil { - log.Fatalln("error creating s3 client:", err) - } - - logger := log.New(os.Stdout, "", log.Ldate|log.Ltime) - - r := mux.NewRouter().StrictSlash(true) - r. - Methods(http.MethodGet). - Path("/"). - Handler(adapters.Adapt( - http.RedirectHandler("/buckets", http.StatusPermanentRedirect), - logging.Handler(logger), - )) - r. - Methods(http.MethodGet). - Path("/buckets"). - Handler(adapters.Adapt( - BucketsViewHandler(s3), - logging.Handler(logger), - )) - r. - Methods(http.MethodGet). - Path("/buckets/{bucketName}"). - Handler(adapters.Adapt( - BucketViewHandler(s3), - logging.Handler(logger), - )) - - api := r.PathPrefix("/api").Subrouter().StrictSlash(true) - - br := api.PathPrefix("/buckets").Subrouter().StrictSlash(true) - br. - Methods(http.MethodPost). - Path(""). - Handler(adapters.Adapt( - CreateBucketHandler(s3), - logging.Handler(logger), - )) - br. - Methods(http.MethodDelete). - Path("/{bucketName}"). - Handler(adapters.Adapt( - DeleteBucketHandler(s3), - logging.Handler(logger), - )) - br. - Methods(http.MethodPost). - Headers(headerContentType, contentTypeJSON). - Path("/{bucketName}/objects"). - Handler(adapters.Adapt( - CopyObjectHandler(s3), - logging.Handler(logger), - )) - br. - Methods(http.MethodPost). - HeadersRegexp(headerContentType, contentTypeMultipartForm). - Path("/{bucketName}/objects"). - Handler(adapters.Adapt( - CreateObjectHandler(s3), - logging.Handler(logger), - )) - br. - Methods(http.MethodGet). - Path("/{bucketName}/objects/{objectName}"). - Handler(adapters.Adapt( - GetObjectHandler(s3), - logging.Handler(logger), - )) - br. - Methods(http.MethodDelete). - Path("/{bucketName}/objects/{objectName}"). - Handler(adapters.Adapt( - DeleteObjectHandler(s3), - logging.Handler(logger), - )) - - port := os.Getenv("PORT") - if len(port) == 0 { - port = "8080" - } - log.Fatal(http.ListenAndServe(":"+port, r)) -} diff --git a/manifest.yml b/manifest.yml index 75abe65..79e19b9 100644 --- a/manifest.yml +++ b/manifest.yml @@ -3,8 +3,8 @@ applications: - name: s3-manager host: s3-manager memory: 64M - buildpack: https://github.com/cloudfoundry/go-buildpack.git + buildpack: https://github.com/cloudfoundry/binary-buildpack.git + command: ./entrypoint-cf.sh - env: - S3_ACCESS_KEY_ID: "xxx" - S3_SECRET_ACCESS_KEY: "xxx" + services: + - wp-storage diff --git a/minio.go b/minio.go deleted file mode 100644 index 6762828..0000000 --- a/minio.go +++ /dev/null @@ -1,37 +0,0 @@ -package main - -import ( - "errors" - "os" - - "github.com/minio/minio-go" -) - -// newMinioClient creates a new Minio client -func newMinioClient() (*minio.Client, error) { - var err error - var c *minio.Client - - s3Endpoint := os.Getenv("S3_ENDPOINT") - if s3Endpoint == "" { - s3Endpoint = "s3.amazonaws.com" - } - - s3AccessKeyID := os.Getenv("S3_ACCESS_KEY_ID") - if s3AccessKeyID == "" { - return nil, errors.New("no S3_ACCESS_KEY_ID found") - } - - s3SecretAccessKey := os.Getenv("S3_SECRET_ACCESS_KEY") - if s3SecretAccessKey == "" { - return nil, errors.New("no S3_SECRET_ACCESS_KEY found") - } - - if os.Getenv("V2_SIGNING") == "true" { - c, err = minio.NewV2(s3Endpoint, s3AccessKeyID, s3SecretAccessKey, true) - } else { - c, err = minio.New(s3Endpoint, s3AccessKeyID, s3SecretAccessKey, true) - } - - return c, err -} diff --git a/s3-client-mock.go b/s3-client-mock.go deleted file mode 100644 index b7400c1..0000000 --- a/s3-client-mock.go +++ /dev/null @@ -1,88 +0,0 @@ -package main - -import ( - "errors" - "io" - - minio "github.com/minio/minio-go" -) - -// S3ClientMock is a mocked S3 client -type S3ClientMock struct { - Buckets []minio.BucketInfo - Objects []minio.ObjectInfo - Err error -} - -// CopyObject mocks minio.Client.CopyObject -func (s S3ClientMock) CopyObject(string, string, string, minio.CopyConditions) error { - return s.Err -} - -// GetObject mocks minio.Client.GetObject -func (s S3ClientMock) GetObject(bucketName string, objectName string) (*minio.Object, error) { - if s.Err != nil { - return nil, s.Err - } - - return &minio.Object{}, nil -} - -// ListBuckets mocks minio.Client.ListBuckets -func (s S3ClientMock) ListBuckets() ([]minio.BucketInfo, error) { - return s.Buckets, s.Err -} - -// ListObjectsV2 mocks minio.Client.ListObjectsV2 -func (s S3ClientMock) ListObjectsV2(name string, p string, r bool, d <-chan struct{}) <-chan minio.ObjectInfo { - // Add error if exists - if s.Err != nil { - s.Objects = append(s.Objects, minio.ObjectInfo{ - Err: s.Err, - }) - } - - // Check if bucket exists - found := false - for _, b := range s.Buckets { - if b.Name == name { - found = true - break - } - } - if !found { - s.Objects = append(s.Objects, minio.ObjectInfo{ - Err: errors.New("The specified bucket does not exist."), - }) - - } - - objCh := make(chan minio.ObjectInfo, len(s.Objects)) - defer close(objCh) - - for _, obj := range s.Objects { - objCh <- obj - } - - return objCh -} - -// MakeBucket mocks minio.Client.MakeBucket -func (s S3ClientMock) MakeBucket(string, string) error { - return s.Err -} - -// PutObject mocks minio.Client.PutObject -func (s S3ClientMock) PutObject(string, string, io.Reader, string) (int64, error) { - return 0, s.Err -} - -// RemoveBucket mocks minio.Client.RemoveBucket -func (s S3ClientMock) RemoveBucket(string) error { - return s.Err -} - -// RemoveObject mocks minio.Client.RemoveObject -func (s S3ClientMock) RemoveObject(string, string) error { - return s.Err -} diff --git a/s3-client.go b/s3.go similarity index 83% rename from s3-client.go rename to s3.go index 1d4cf3a..cd1fb76 100644 --- a/s3-client.go +++ b/s3.go @@ -1,4 +1,4 @@ -package main +package s3manager import ( "io" @@ -6,8 +6,8 @@ import ( minio "github.com/minio/minio-go" ) -// S3Client is a client to interact with S3 storage -type S3Client interface { +// S3 is a client to interact with S3 storage. +type S3 interface { CopyObject(string, string, string, minio.CopyConditions) error GetObject(string, string) (*minio.Object, error) ListBuckets() ([]minio.BucketInfo, error) diff --git a/s3_test.go b/s3_test.go new file mode 100644 index 0000000..ad56b51 --- /dev/null +++ b/s3_test.go @@ -0,0 +1,88 @@ +package s3manager_test + +import ( + "errors" + "io" + + minio "github.com/minio/minio-go" +) + +// s3Mock is a mocked S3 client. +type s3Mock struct { + Buckets []minio.BucketInfo + Objects []minio.ObjectInfo + Err error +} + +// CopyObject mocks minio.Client.CopyObject. +func (s *s3Mock) CopyObject(string, string, string, minio.CopyConditions) error { + return s.Err +} + +// GetObject mocks minio.Client.GetObject. +func (s *s3Mock) GetObject(bucketName string, objectName string) (*minio.Object, error) { + if s.Err != nil { + return nil, s.Err + } + + return &minio.Object{}, nil +} + +// ListBuckets mocks minio.Client.ListBuckets. +func (s *s3Mock) ListBuckets() ([]minio.BucketInfo, error) { + return s.Buckets, s.Err +} + +// ListObjectsV2 mocks minio.Client.ListObjectsV2. +func (s *s3Mock) ListObjectsV2(name string, p string, r bool, d <-chan struct{}) <-chan minio.ObjectInfo { + // Add error if exists + if s.Err != nil { + s.Objects = append(s.Objects, minio.ObjectInfo{ + Err: s.Err, + }) + } + + // Check if bucket exists + found := false + for _, b := range s.Buckets { + if b.Name == name { + found = true + break + } + } + if !found { + s.Objects = append(s.Objects, minio.ObjectInfo{ + Err: errors.New("The specified bucket does not exist."), + }) + + } + + objCh := make(chan minio.ObjectInfo, len(s.Objects)) + defer close(objCh) + + for _, obj := range s.Objects { + objCh <- obj + } + + return objCh +} + +// MakeBucket mocks minio.Client.MakeBucket. +func (s *s3Mock) MakeBucket(string, string) error { + return s.Err +} + +// PutObject mocks minio.Client.PutObject. +func (s *s3Mock) PutObject(string, string, io.Reader, string) (int64, error) { + return 0, s.Err +} + +// RemoveBucket mocks minio.Client.RemoveBucket. +func (s *s3Mock) RemoveBucket(string) error { + return s.Err +} + +// RemoveObject mocks minio.Client.RemoveObject. +func (s *s3Mock) RemoveObject(string, string) error { + return s.Err +} diff --git a/s3manager.go b/s3manager.go new file mode 100644 index 0000000..f76b391 --- /dev/null +++ b/s3manager.go @@ -0,0 +1,12 @@ +// Package s3manager allows to interact with an S3 compatible storage. +package s3manager + +// Constants commonly used throughout the application. +const ( + HeaderContentType = "Content-Type" + ContentTypeJSON = "application/json" + ContentTypeMultipartForm = "multipart/form-data" + tmplDirectory = "templates" + headerContentDisposition = "Content-Disposition" + contentTypeOctetStream = "application/octet-stream" +) diff --git a/templates/bucket.html.tmpl b/templates/bucket.html.tmpl index 9623e22..2514c5d 100644 --- a/templates/bucket.html.tmpl +++ b/templates/bucket.html.tmpl @@ -1,109 +1,112 @@ {{ define "content" }}
- arrow_back Buckets + arrow_back Buckets - {{ if .Objects }} - + {{ if .Objects }} +
- - - - - - - - - - + + + + + + + + + + - - {{ range $index, $object := .Objects }} - - - - - - - + {{ range $index, $object := .Objects }} + + + + + + + - - {{ end }} - + + + + + {{ end }} + -
KeySizeOwnerLast Modified
KeySizeOwnerLast Modified
{{ $object.Icon }}{{ $object.Key }}{{ $object.Size }} bytes{{ $object.Owner }}{{ $object.LastModified }} - - - Actions arrow_drop_down - +
{{ $object.Icon }}{{ $object.Key }}{{ $object.Size }} bytes{{ $object.Owner }}{{ $object.LastModified }} + + + Actions arrow_drop_down + - - -
- {{ end }} + + {{ end }} - {{ if not .Objects }} -

No objects in {{ .BucketName }} yet

- {{ end }} + {{ if not .Objects }} +

No objects in {{ .BucketName }} yet

+ {{ end }}