├── .dockerignore ├── .cfignore ├── screenshot.png ├── internal └── app │ └── s3manager │ ├── s3manager.go │ ├── errors_test.go │ ├── delete_bucket.go │ ├── delete_object.go │ ├── create_bucket.go │ ├── get_object.go │ ├── errors.go │ ├── buckets_view.go │ ├── s3.go │ ├── generate_presigned_url.go │ ├── create_object.go │ ├── delete_object_test.go │ ├── delete_bucket_test.go │ ├── instance_handlers.go │ ├── get_object_test.go │ ├── buckets_view_test.go │ ├── create_bucket_test.go │ ├── bucket_view.go │ ├── multi_s3_manager.go │ ├── bucket_view_test.go │ ├── manager_handlers.go │ └── mocks │ └── s3.go ├── web ├── static │ ├── css │ │ ├── 2fcrYFNaTjcS6g4U3t-Y5ZjZjT5FdEJ140U2DJYC3mY.woff2 │ │ └── material-fonts.css │ └── js │ │ └── jquery-3.6.0.min.js └── template │ ├── buckets.html.tmpl │ ├── layout.html.tmpl │ └── bucket.html.tmpl ├── .goreleaser.yml ├── Makefile ├── .gitignore ├── Dockerfile ├── .github └── workflows │ ├── release.yml │ └── main.yml ├── LICENSE ├── docker-compose.yml ├── go.mod ├── README.md ├── go.sum └── main.go /.dockerignore: -------------------------------------------------------------------------------- 1 | bin 2 | -------------------------------------------------------------------------------- /.cfignore: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | !/bin/s3manager 4 | !/web/ 5 | !/deployments/cf/ 6 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudlena/s3manager/HEAD/screenshot.png -------------------------------------------------------------------------------- /internal/app/s3manager/s3manager.go: -------------------------------------------------------------------------------- 1 | // Package s3manager allows to interact with an S3 compatible storage. 2 | package s3manager 3 | -------------------------------------------------------------------------------- /web/static/css/2fcrYFNaTjcS6g4U3t-Y5ZjZjT5FdEJ140U2DJYC3mY.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudlena/s3manager/HEAD/web/static/css/2fcrYFNaTjcS6g4U3t-Y5ZjZjT5FdEJ140U2DJYC3mY.woff2 -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | goos: 8 | - linux 9 | - windows 10 | - darwin 11 | checksum: 12 | name_template: "checksums.txt" 13 | -------------------------------------------------------------------------------- /internal/app/s3manager/errors_test.go: -------------------------------------------------------------------------------- 1 | package s3manager_test 2 | 3 | import "errors" 4 | 5 | var ( 6 | errS3 = errors.New("mocked s3 error") 7 | errBucketDoesNotExist = errors.New("error: The specified bucket does not exist") 8 | ) 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: 3 | go build -o bin/s3manager 4 | 5 | .PHONY: run 6 | run: 7 | go run 8 | 9 | .PHONY: lint 10 | lint: 11 | golangci-lint run 12 | 13 | .PHONY: test 14 | test: 15 | go test -race -cover ./... 16 | 17 | .PHONY: build-image 18 | build-image: 19 | docker build -t s3manager . 20 | 21 | .PHONY: clean 22 | clean: 23 | rm -rf bin 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | bin/ 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | # Go workspace file 22 | go.work 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/library/golang:1 AS builder 2 | WORKDIR /usr/src/app 3 | COPY . ./ 4 | RUN CGO_ENABLED=0 go build -ldflags="-s -w" -a -installsuffix cgo -o bin/s3manager 5 | 6 | FROM docker.io/library/alpine:latest 7 | WORKDIR /usr/src/app 8 | RUN addgroup -S s3manager && adduser -S s3manager -G s3manager 9 | RUN apk add --no-cache \ 10 | ca-certificates \ 11 | dumb-init 12 | COPY --from=builder --chown=s3manager:s3manager /usr/src/app/bin/s3manager ./ 13 | USER s3manager 14 | EXPOSE 8080 15 | ENTRYPOINT [ "/usr/bin/dumb-init", "--" ] 16 | CMD [ "/usr/src/app/s3manager" ] 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/setup-go@v6 14 | with: 15 | go-version: "1.25" 16 | - uses: actions/checkout@v5 17 | - uses: golangci/golangci-lint-action@v8 18 | - run: make test 19 | - uses: goreleaser/goreleaser-action@v6 20 | with: 21 | args: release --clean 22 | env: 23 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 24 | -------------------------------------------------------------------------------- /internal/app/s3manager/delete_bucket.go: -------------------------------------------------------------------------------- 1 | package s3manager 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | ) 9 | 10 | // HandleDeleteBucket deletes a bucket. 11 | func HandleDeleteBucket(s3 S3) http.HandlerFunc { 12 | return func(w http.ResponseWriter, r *http.Request) { 13 | bucketName := mux.Vars(r)["bucketName"] 14 | 15 | err := s3.RemoveBucket(r.Context(), bucketName) 16 | if err != nil { 17 | handleHTTPError(w, fmt.Errorf("error removing bucket: %w", err)) 18 | return 19 | } 20 | 21 | w.WriteHeader(http.StatusNoContent) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Lena Fuhrimann 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /web/static/css/material-fonts.css: -------------------------------------------------------------------------------- 1 | /* fallback */ 2 | @font-face { 3 | font-family: 'Material Icons'; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: url(2fcrYFNaTjcS6g4U3t-Y5ZjZjT5FdEJ140U2DJYC3mY.woff2) format('woff2'); 7 | } 8 | 9 | .material-icons { 10 | font-family: 'Material Icons'; 11 | font-weight: normal; 12 | font-style: normal; 13 | font-size: 24px; 14 | line-height: 1; 15 | letter-spacing: normal; 16 | text-transform: none; 17 | display: inline-block; 18 | white-space: nowrap; 19 | word-wrap: normal; 20 | direction: ltr; 21 | -webkit-font-feature-settings: 'liga'; 22 | -webkit-font-smoothing: antialiased; 23 | } -------------------------------------------------------------------------------- /internal/app/s3manager/delete_object.go: -------------------------------------------------------------------------------- 1 | package s3manager 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | "github.com/minio/minio-go/v7" 9 | ) 10 | 11 | // HandleDeleteObject deletes an object. 12 | func HandleDeleteObject(s3 S3) http.HandlerFunc { 13 | return func(w http.ResponseWriter, r *http.Request) { 14 | bucketName := mux.Vars(r)["bucketName"] 15 | objectName := mux.Vars(r)["objectName"] 16 | 17 | err := s3.RemoveObject(r.Context(), bucketName, objectName, minio.RemoveObjectOptions{}) 18 | if err != nil { 19 | handleHTTPError(w, fmt.Errorf("error removing object: %w", err)) 20 | return 21 | } 22 | 23 | w.WriteHeader(http.StatusNoContent) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/app/s3manager/create_bucket.go: -------------------------------------------------------------------------------- 1 | package s3manager 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/minio/minio-go/v7" 9 | ) 10 | 11 | // HandleCreateBucket creates a new bucket. 12 | func HandleCreateBucket(s3 S3) http.HandlerFunc { 13 | return func(w http.ResponseWriter, r *http.Request) { 14 | var bucket minio.BucketInfo 15 | err := json.NewDecoder(r.Body).Decode(&bucket) 16 | if err != nil { 17 | handleHTTPError(w, fmt.Errorf("error decoding body JSON: %w", err)) 18 | return 19 | } 20 | 21 | err = s3.MakeBucket(r.Context(), bucket.Name, minio.MakeBucketOptions{}) 22 | if err != nil { 23 | handleHTTPError(w, fmt.Errorf("error making bucket: %w", err)) 24 | return 25 | } 26 | 27 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 28 | w.WriteHeader(http.StatusCreated) 29 | err = json.NewEncoder(w).Encode(bucket) 30 | if err != nil { 31 | handleHTTPError(w, fmt.Errorf("error encoding JSON: %w", err)) 32 | return 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Verify and Push 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | verify: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Set up Go 14 | uses: actions/setup-go@v6 15 | with: 16 | go-version: "1.25" 17 | - name: Checkout repo 18 | uses: actions/checkout@v5 19 | - name: Lint code 20 | uses: golangci/golangci-lint-action@v9 21 | - name: Run tests 22 | run: make test 23 | - name: Log in to Docker Hub 24 | uses: docker/login-action@v3 25 | with: 26 | username: cloudlena 27 | password: ${{ secrets.DOCKERHUB_TOKEN }} 28 | - name: Set up container image build 29 | uses: docker/setup-buildx-action@v3 30 | - name: Build and push container image 31 | uses: docker/build-push-action@v6 32 | with: 33 | push: true 34 | tags: cloudlena/s3manager:latest 35 | platforms: linux/amd64,linux/arm64 36 | -------------------------------------------------------------------------------- /internal/app/s3manager/get_object.go: -------------------------------------------------------------------------------- 1 | package s3manager 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/minio/minio-go/v7" 10 | ) 11 | 12 | // HandleGetObject downloads an object to the client. 13 | func HandleGetObject(s3 S3, forceDownload bool) http.HandlerFunc { 14 | return func(w http.ResponseWriter, r *http.Request) { 15 | bucketName := mux.Vars(r)["bucketName"] 16 | objectName := mux.Vars(r)["objectName"] 17 | 18 | object, err := s3.GetObject(r.Context(), bucketName, objectName, minio.GetObjectOptions{}) 19 | if err != nil { 20 | handleHTTPError(w, fmt.Errorf("error getting object: %w", err)) 21 | return 22 | } 23 | 24 | if forceDownload { 25 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", objectName)) 26 | w.Header().Set("Content-Type", "application/octet-stream") 27 | } 28 | _, err = io.Copy(w, object) 29 | if err != nil { 30 | handleHTTPError(w, fmt.Errorf("error copying object to response writer: %w", err)) 31 | return 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/app/s3manager/errors.go: -------------------------------------------------------------------------------- 1 | package s3manager 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | "log" 8 | "net/http" 9 | "strings" 10 | ) 11 | 12 | // Error codes that may be returned from an S3 client. 13 | const ( 14 | ErrBucketDoesNotExist = "The specified bucket does not exist" 15 | ErrKeyDoesNotExist = "The specified key does not exist" 16 | ) 17 | 18 | // handleHTTPError handles HTTP errors. 19 | func handleHTTPError(w http.ResponseWriter, err error) { 20 | code := http.StatusInternalServerError 21 | 22 | var se *json.SyntaxError 23 | ok := errors.As(err, &se) 24 | if ok { 25 | code = http.StatusUnprocessableEntity 26 | } 27 | 28 | switch { 29 | case errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF): 30 | code = http.StatusUnprocessableEntity 31 | case strings.Contains(err.Error(), ErrBucketDoesNotExist) || strings.Contains(err.Error(), ErrKeyDoesNotExist): 32 | code = http.StatusNotFound 33 | } 34 | 35 | http.Error(w, http.StatusText(code), code) 36 | 37 | // Log if server error 38 | if code >= http.StatusInternalServerError { 39 | log.Println(err) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/app/s3manager/buckets_view.go: -------------------------------------------------------------------------------- 1 | package s3manager 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "io/fs" 7 | "net/http" 8 | 9 | "github.com/minio/minio-go/v7" 10 | ) 11 | 12 | // HandleBucketsView renders all buckets on an HTML page. 13 | func HandleBucketsView(s3 S3, templates fs.FS, allowDelete bool, rootURL string) http.HandlerFunc { 14 | type pageData struct { 15 | RootURL string 16 | Buckets []minio.BucketInfo 17 | AllowDelete bool 18 | } 19 | 20 | return func(w http.ResponseWriter, r *http.Request) { 21 | buckets, err := s3.ListBuckets(r.Context()) 22 | 23 | if err != nil { 24 | handleHTTPError(w, fmt.Errorf("error listing buckets: %w", err)) 25 | return 26 | } 27 | 28 | data := pageData{ 29 | RootURL: rootURL, 30 | Buckets: buckets, 31 | AllowDelete: allowDelete, 32 | } 33 | 34 | t, err := template.ParseFS(templates, "layout.html.tmpl", "buckets.html.tmpl") 35 | if err != nil { 36 | handleHTTPError(w, fmt.Errorf("error parsing template files: %w", err)) 37 | return 38 | } 39 | err = t.ExecuteTemplate(w, "layout", data) 40 | if err != nil { 41 | handleHTTPError(w, fmt.Errorf("error executing template: %w", err)) 42 | return 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/app/s3manager/s3.go: -------------------------------------------------------------------------------- 1 | package s3manager 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/url" 7 | "time" 8 | 9 | "github.com/minio/minio-go/v7" 10 | ) 11 | 12 | //go:generate moq -out mocks/s3.go -pkg mocks . S3 13 | 14 | // S3 is a client to interact with S3 storage. 15 | type S3 interface { 16 | GetObject(ctx context.Context, bucketName, objectName string, opts minio.GetObjectOptions) (*minio.Object, error) 17 | ListBuckets(ctx context.Context) ([]minio.BucketInfo, error) 18 | ListObjects(ctx context.Context, bucketName string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo 19 | MakeBucket(ctx context.Context, bucketName string, opts minio.MakeBucketOptions) error 20 | PresignedGetObject(ctx context.Context, bucketName, objectName string, expiry time.Duration, reqParams url.Values) (*url.URL, error) 21 | PutObject(ctx context.Context, bucketName, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (minio.UploadInfo, error) 22 | RemoveBucket(ctx context.Context, bucketName string) error 23 | RemoveObject(ctx context.Context, bucketName, objectName string, opts minio.RemoveObjectOptions) error 24 | } 25 | 26 | // SSEType describes a type of server side encryption. 27 | type SSEType struct { 28 | Type string 29 | Key string 30 | } 31 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | s3manager: 3 | container_name: s3manager 4 | build: . 5 | ports: 6 | - 8080:8080 7 | environment: 8 | # S3 Instance 1: my-first-instance 9 | - 1_NAME=my-first-instance 10 | - 1_ENDPOINT=s3:9000 11 | - 1_ACCESS_KEY_ID=s3manager 12 | - 1_SECRET_ACCESS_KEY=s3manager 13 | - 1_USE_SSL=false 14 | # S3 Instance 2: my-second-instance 15 | - 2_NAME=my-second-instance 16 | - 2_ENDPOINT=s3-2:9000 17 | - 2_ACCESS_KEY_ID=s3manager2 18 | - 2_SECRET_ACCESS_KEY=s3manager2 19 | - 2_USE_SSL=false 20 | depends_on: 21 | - s3 22 | - s3-2 23 | s3: 24 | container_name: s3 25 | image: docker.io/minio/minio 26 | command: server /data 27 | ports: 28 | - 9000:9000 29 | - 9001:9001 30 | environment: 31 | - MINIO_ACCESS_KEY=s3manager 32 | - MINIO_SECRET_KEY=s3manager 33 | - MINIO_ADDRESS=0.0.0.0:9000 34 | - MINIO_CONSOLE_ADDRESS=0.0.0.0:9001 35 | s3-2: 36 | container_name: s3-2 37 | image: docker.io/minio/minio 38 | command: server /data 39 | ports: 40 | - 9002:9000 41 | - 9003:9001 42 | environment: 43 | - MINIO_ACCESS_KEY=s3manager2 44 | - MINIO_SECRET_KEY=s3manager2 45 | - MINIO_ADDRESS=0.0.0.0:9000 46 | - MINIO_CONSOLE_ADDRESS=0.0.0.0:9001 47 | -------------------------------------------------------------------------------- /internal/app/s3manager/generate_presigned_url.go: -------------------------------------------------------------------------------- 1 | package s3manager 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/gorilla/mux" 12 | ) 13 | 14 | // HandleGenerateURL generates a presigned URL. 15 | func HandleGenerateURL(s3 S3) http.HandlerFunc { 16 | return func(w http.ResponseWriter, r *http.Request) { 17 | bucketName := mux.Vars(r)["bucketName"] 18 | objectName := mux.Vars(r)["objectName"] 19 | expiry := r.URL.Query().Get("expiry") 20 | 21 | parsedExpiry, err := strconv.ParseInt(expiry, 10, 0) 22 | if err != nil { 23 | handleHTTPError(w, fmt.Errorf("error converting expiry: %w", err)) 24 | return 25 | } 26 | 27 | if parsedExpiry > 7*24*60*60 || parsedExpiry < 1 { 28 | handleHTTPError(w, fmt.Errorf("invalid expiry value: %v", parsedExpiry)) 29 | return 30 | } 31 | 32 | expiryDuration := time.Duration(parsedExpiry) * time.Second 33 | reqParams := make(url.Values) 34 | url, err := s3.PresignedGetObject(r.Context(), bucketName, objectName, expiryDuration, reqParams) 35 | if err != nil { 36 | handleHTTPError(w, fmt.Errorf("error generating url: %w", err)) 37 | return 38 | } 39 | 40 | encoder := json.NewEncoder(w) 41 | encoder.SetEscapeHTML(false) 42 | err = encoder.Encode(map[string]string{"url": url.String()}) 43 | if err != nil { 44 | handleHTTPError(w, fmt.Errorf("error encoding JSON: %w", err)) 45 | return 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cloudlena/s3manager 2 | 3 | go 1.25.4 4 | 5 | require ( 6 | github.com/cloudlena/adapters v0.0.0-20251206101801-0ca4d9e6fb6b 7 | github.com/gorilla/mux v1.8.1 8 | github.com/matryer/is v1.4.1 9 | github.com/minio/minio-go/v7 v7.0.97 10 | github.com/spf13/viper v1.21.0 11 | ) 12 | 13 | require ( 14 | github.com/dustin/go-humanize v1.0.1 // indirect 15 | github.com/fsnotify/fsnotify v1.9.0 // indirect 16 | github.com/go-ini/ini v1.67.0 // indirect 17 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 18 | github.com/google/uuid v1.6.0 // indirect 19 | github.com/klauspost/compress v1.18.2 // indirect 20 | github.com/klauspost/cpuid/v2 v2.3.0 // indirect 21 | github.com/klauspost/crc32 v1.3.0 // indirect 22 | github.com/minio/crc64nvme v1.1.1 // indirect 23 | github.com/minio/md5-simd v1.1.2 // indirect 24 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 25 | github.com/philhofer/fwd v1.2.0 // indirect 26 | github.com/rs/xid v1.6.0 // indirect 27 | github.com/sagikazarmark/locafero v0.12.0 // indirect 28 | github.com/spf13/afero v1.15.0 // indirect 29 | github.com/spf13/cast v1.10.0 // indirect 30 | github.com/spf13/pflag v1.0.10 // indirect 31 | github.com/subosito/gotenv v1.6.0 // indirect 32 | github.com/tinylib/msgp v1.6.1 // indirect 33 | go.yaml.in/yaml/v3 v3.0.4 // indirect 34 | golang.org/x/crypto v0.45.0 // indirect 35 | golang.org/x/net v0.47.0 // indirect 36 | golang.org/x/sys v0.38.0 // indirect 37 | golang.org/x/text v0.31.0 // indirect 38 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 39 | gopkg.in/yaml.v3 v3.0.1 // indirect 40 | ) 41 | -------------------------------------------------------------------------------- /internal/app/s3manager/create_object.go: -------------------------------------------------------------------------------- 1 | package s3manager 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/minio/minio-go/v7" 10 | "github.com/minio/minio-go/v7/pkg/encrypt" 11 | ) 12 | 13 | // HandleCreateObject uploads a new object. 14 | func HandleCreateObject(s3 S3, sseInfo SSEType) http.HandlerFunc { 15 | return func(w http.ResponseWriter, r *http.Request) { 16 | bucketName := mux.Vars(r)["bucketName"] 17 | 18 | err := r.ParseMultipartForm(32 << 20) // 32 Mb 19 | if err != nil { 20 | handleHTTPError(w, fmt.Errorf("error parsing multipart form: %w", err)) 21 | return 22 | } 23 | file, fileHeader, err := r.FormFile("file") 24 | path := r.FormValue("path") 25 | if err != nil { 26 | handleHTTPError(w, fmt.Errorf("error getting file from form: %w", err)) 27 | return 28 | } 29 | defer func() { 30 | if cErr := file.Close(); cErr != nil { 31 | log.Printf("error closing file: %v", cErr) 32 | } 33 | }() 34 | 35 | opts := minio.PutObjectOptions{ContentType: "application/octet-stream"} 36 | 37 | if sseInfo.Type == "KMS" { 38 | opts.ServerSideEncryption, err = encrypt.NewSSEKMS(sseInfo.Key, nil) 39 | if err != nil { 40 | handleHTTPError(w, fmt.Errorf("error setting SSE-KMS key: %w", err)) 41 | return 42 | } 43 | } 44 | 45 | if sseInfo.Type == "SSE" { 46 | opts.ServerSideEncryption = encrypt.NewSSE() 47 | } 48 | 49 | if sseInfo.Type == "SSE-C" { 50 | opts.ServerSideEncryption, err = encrypt.NewSSEC([]byte(sseInfo.Key)) 51 | if err != nil { 52 | handleHTTPError(w, fmt.Errorf("error setting SSE-C key: %w", err)) 53 | return 54 | } 55 | } 56 | 57 | size := fileHeader.Size 58 | _, err = s3.PutObject(r.Context(), bucketName, path, file, size, opts) 59 | if err != nil { 60 | handleHTTPError(w, fmt.Errorf("error putting object: %w", err)) 61 | return 62 | } 63 | 64 | w.WriteHeader(http.StatusCreated) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/app/s3manager/delete_object_test.go: -------------------------------------------------------------------------------- 1 | package s3manager_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/cloudlena/s3manager/internal/app/s3manager" 11 | "github.com/cloudlena/s3manager/internal/app/s3manager/mocks" 12 | "github.com/matryer/is" 13 | "github.com/minio/minio-go/v7" 14 | ) 15 | 16 | func TestHandleDeleteObject(t *testing.T) { 17 | t.Parallel() 18 | 19 | cases := []struct { 20 | it string 21 | removeObjectFunc func(context.Context, string, string, minio.RemoveObjectOptions) error 22 | expectedStatusCode int 23 | expectedBodyContains string 24 | }{ 25 | { 26 | it: "deletes an existing object", 27 | removeObjectFunc: func(context.Context, string, string, minio.RemoveObjectOptions) error { 28 | return nil 29 | }, 30 | expectedStatusCode: http.StatusNoContent, 31 | expectedBodyContains: "", 32 | }, 33 | { 34 | it: "returns error if there is an S3 error", 35 | removeObjectFunc: func(context.Context, string, string, minio.RemoveObjectOptions) error { 36 | return errS3 37 | }, 38 | expectedStatusCode: http.StatusInternalServerError, 39 | expectedBodyContains: http.StatusText(http.StatusInternalServerError), 40 | }, 41 | } 42 | 43 | for _, tc := range cases { 44 | t.Run(tc.it, func(t *testing.T) { 45 | t.Parallel() 46 | is := is.New(t) 47 | 48 | s3 := &mocks.S3Mock{ 49 | RemoveObjectFunc: tc.removeObjectFunc, 50 | } 51 | 52 | req, err := http.NewRequest(http.MethodDelete, "/api/buckets/bucketName/objects/objectName", nil) 53 | is.NoErr(err) 54 | 55 | rr := httptest.NewRecorder() 56 | handler := s3manager.HandleDeleteObject(s3) 57 | 58 | handler.ServeHTTP(rr, req) 59 | 60 | is.Equal(tc.expectedStatusCode, rr.Code) // status code 61 | is.True(strings.Contains(rr.Body.String(), tc.expectedBodyContains)) // body 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/app/s3manager/delete_bucket_test.go: -------------------------------------------------------------------------------- 1 | package s3manager_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/cloudlena/s3manager/internal/app/s3manager" 12 | "github.com/cloudlena/s3manager/internal/app/s3manager/mocks" 13 | "github.com/matryer/is" 14 | ) 15 | 16 | func TestHandleDeleteBucket(t *testing.T) { 17 | t.Parallel() 18 | 19 | cases := []struct { 20 | it string 21 | removeBucketFunc func(context.Context, string) error 22 | expectedStatusCode int 23 | expectedBodyContains string 24 | }{ 25 | { 26 | it: "deletes an existing bucket", 27 | removeBucketFunc: func(context.Context, string) error { 28 | return nil 29 | }, 30 | expectedStatusCode: http.StatusNoContent, 31 | expectedBodyContains: "", 32 | }, 33 | { 34 | it: "returns error if there is an S3 error", 35 | removeBucketFunc: func(context.Context, string) error { 36 | return errS3 37 | }, 38 | expectedStatusCode: http.StatusInternalServerError, 39 | expectedBodyContains: http.StatusText(http.StatusInternalServerError), 40 | }, 41 | } 42 | 43 | for _, tc := range cases { 44 | t.Run(tc.it, func(t *testing.T) { 45 | t.Parallel() 46 | is := is.New(t) 47 | 48 | s3 := &mocks.S3Mock{ 49 | RemoveBucketFunc: tc.removeBucketFunc, 50 | } 51 | 52 | req, err := http.NewRequest(http.MethodDelete, "/api/buckets/BUCKET-NAME", nil) 53 | is.NoErr(err) 54 | 55 | rr := httptest.NewRecorder() 56 | handler := s3manager.HandleDeleteBucket(s3) 57 | 58 | handler.ServeHTTP(rr, req) 59 | resp := rr.Result() 60 | defer func() { 61 | err = resp.Body.Close() 62 | is.NoErr(err) 63 | }() 64 | body, err := io.ReadAll(resp.Body) 65 | is.NoErr(err) 66 | 67 | is.Equal(tc.expectedStatusCode, resp.StatusCode) // status code 68 | is.True(strings.Contains(string(body), tc.expectedBodyContains)) // body 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /internal/app/s3manager/instance_handlers.go: -------------------------------------------------------------------------------- 1 | package s3manager 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | ) 9 | 10 | // S3InstanceInfo represents the information about an S3 instance for API responses 11 | type S3InstanceInfo struct { 12 | ID string `json:"id"` 13 | Name string `json:"name"` 14 | } 15 | 16 | // HandleGetS3Instances returns all available S3 instances 17 | func HandleGetS3Instances(manager *MultiS3Manager) http.HandlerFunc { 18 | return func(w http.ResponseWriter, r *http.Request) { 19 | instances := manager.GetAllInstances() 20 | current := manager.GetCurrentInstance() 21 | 22 | response := struct { 23 | Instances []S3InstanceInfo `json:"instances"` 24 | Current string `json:"current"` 25 | }{ 26 | Instances: make([]S3InstanceInfo, len(instances)), 27 | Current: current.ID, 28 | } 29 | 30 | for i, instance := range instances { 31 | response.Instances[i] = S3InstanceInfo{ 32 | ID: instance.ID, 33 | Name: instance.Name, 34 | } 35 | } 36 | 37 | w.Header().Set("Content-Type", "application/json") 38 | json.NewEncoder(w).Encode(response) 39 | } 40 | } 41 | 42 | // HandleSwitchS3Instance switches to a specific S3 instance 43 | func HandleSwitchS3Instance(manager *MultiS3Manager) http.HandlerFunc { 44 | return func(w http.ResponseWriter, r *http.Request) { 45 | vars := mux.Vars(r) 46 | instanceID := vars["instanceId"] 47 | 48 | if instanceID == "" { 49 | http.Error(w, "instance ID is required", http.StatusBadRequest) 50 | return 51 | } 52 | 53 | err := manager.SetCurrentInstance(instanceID) 54 | if err != nil { 55 | http.Error(w, err.Error(), http.StatusNotFound) 56 | return 57 | } 58 | 59 | current := manager.GetCurrentInstance() 60 | response := S3InstanceInfo{ 61 | ID: current.ID, 62 | Name: current.Name, 63 | } 64 | 65 | w.Header().Set("Content-Type", "application/json") 66 | json.NewEncoder(w).Encode(response) 67 | } 68 | } -------------------------------------------------------------------------------- /internal/app/s3manager/get_object_test.go: -------------------------------------------------------------------------------- 1 | package s3manager_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/cloudlena/s3manager/internal/app/s3manager" 13 | "github.com/cloudlena/s3manager/internal/app/s3manager/mocks" 14 | "github.com/gorilla/mux" 15 | "github.com/matryer/is" 16 | "github.com/minio/minio-go/v7" 17 | ) 18 | 19 | func TestHandleGetObject(t *testing.T) { 20 | t.Parallel() 21 | 22 | cases := []struct { 23 | it string 24 | getObjectFunc func(context.Context, string, string, minio.GetObjectOptions) (*minio.Object, error) 25 | bucketName string 26 | objectName string 27 | expectedStatusCode int 28 | expectedBodyContains string 29 | }{ 30 | { 31 | it: "returns error if there is an S3 error", 32 | getObjectFunc: func(context.Context, string, string, minio.GetObjectOptions) (*minio.Object, error) { 33 | return nil, errS3 34 | }, 35 | bucketName: "BUCKET-NAME", 36 | objectName: "OBJECT-NAME", 37 | expectedStatusCode: http.StatusInternalServerError, 38 | expectedBodyContains: http.StatusText(http.StatusInternalServerError), 39 | }, 40 | } 41 | 42 | for _, tc := range cases { 43 | t.Run(tc.it, func(t *testing.T) { 44 | t.Parallel() 45 | is := is.New(t) 46 | 47 | s3 := &mocks.S3Mock{ 48 | GetObjectFunc: tc.getObjectFunc, 49 | } 50 | 51 | r := mux.NewRouter() 52 | r.Handle("/buckets/{bucketName}/objects/{objectName}", s3manager.HandleGetObject(s3, true)).Methods(http.MethodGet) 53 | 54 | ts := httptest.NewServer(r) 55 | defer ts.Close() 56 | 57 | resp, err := http.Get(fmt.Sprintf("%s/buckets/%s/objects/%s", ts.URL, tc.bucketName, tc.objectName)) 58 | is.NoErr(err) 59 | defer func() { 60 | err = resp.Body.Close() 61 | is.NoErr(err) 62 | }() 63 | body, err := io.ReadAll(resp.Body) 64 | is.NoErr(err) 65 | 66 | is.Equal(tc.expectedStatusCode, resp.StatusCode) // status code 67 | is.True(strings.Contains(string(body), tc.expectedBodyContains)) // body 68 | }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /internal/app/s3manager/buckets_view_test.go: -------------------------------------------------------------------------------- 1 | package s3manager_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/cloudlena/s3manager/internal/app/s3manager" 14 | "github.com/cloudlena/s3manager/internal/app/s3manager/mocks" 15 | "github.com/matryer/is" 16 | "github.com/minio/minio-go/v7" 17 | ) 18 | 19 | func TestHandleBucketsView(t *testing.T) { 20 | t.Parallel() 21 | 22 | cases := []struct { 23 | it string 24 | listBucketsFunc func(context.Context) ([]minio.BucketInfo, error) 25 | expectedStatusCode int 26 | expectedBodyContains string 27 | }{ 28 | { 29 | it: "renders a list of buckets", 30 | listBucketsFunc: func(context.Context) ([]minio.BucketInfo, error) { 31 | return []minio.BucketInfo{{Name: "BUCKET-NAME"}}, nil 32 | }, 33 | expectedStatusCode: http.StatusOK, 34 | expectedBodyContains: "BUCKET-NAME", 35 | }, 36 | { 37 | it: "renders placeholder if no buckets", 38 | listBucketsFunc: func(context.Context) ([]minio.BucketInfo, error) { 39 | return []minio.BucketInfo{}, nil 40 | }, 41 | expectedStatusCode: http.StatusOK, 42 | expectedBodyContains: "No buckets yet", 43 | }, 44 | { 45 | it: "returns error if there is an S3 error", 46 | listBucketsFunc: func(context.Context) ([]minio.BucketInfo, error) { 47 | return []minio.BucketInfo{}, errS3 48 | }, 49 | expectedStatusCode: http.StatusInternalServerError, 50 | expectedBodyContains: http.StatusText(http.StatusInternalServerError), 51 | }, 52 | } 53 | 54 | for _, tc := range cases { 55 | t.Run(tc.it, func(t *testing.T) { 56 | t.Parallel() 57 | is := is.New(t) 58 | 59 | s3 := &mocks.S3Mock{ 60 | ListBucketsFunc: tc.listBucketsFunc, 61 | } 62 | 63 | templates := os.DirFS(filepath.Join("..", "..", "..", "web", "template")) 64 | 65 | req, err := http.NewRequest(http.MethodGet, "/buckets", nil) 66 | is.NoErr(err) 67 | 68 | rr := httptest.NewRecorder() 69 | handler := s3manager.HandleBucketsView(s3, templates, true, "") 70 | 71 | handler.ServeHTTP(rr, req) 72 | resp := rr.Result() 73 | defer func() { 74 | err = resp.Body.Close() 75 | is.NoErr(err) 76 | }() 77 | body, err := io.ReadAll(resp.Body) 78 | is.NoErr(err) 79 | 80 | is.Equal(tc.expectedStatusCode, resp.StatusCode) // status code 81 | is.True(strings.Contains(string(body), tc.expectedBodyContains)) // body 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /internal/app/s3manager/create_bucket_test.go: -------------------------------------------------------------------------------- 1 | package s3manager_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/cloudlena/s3manager/internal/app/s3manager" 13 | "github.com/cloudlena/s3manager/internal/app/s3manager/mocks" 14 | "github.com/matryer/is" 15 | "github.com/minio/minio-go/v7" 16 | ) 17 | 18 | func TestHandleCreateBucket(t *testing.T) { 19 | t.Parallel() 20 | 21 | cases := []struct { 22 | it string 23 | makeBucketFunc func(context.Context, string, minio.MakeBucketOptions) error 24 | body string 25 | expectedStatusCode int 26 | expectedBodyContains string 27 | }{ 28 | { 29 | it: "creates a new bucket", 30 | makeBucketFunc: func(context.Context, string, minio.MakeBucketOptions) error { 31 | return nil 32 | }, 33 | body: `{"name":"BUCKET-NAME"}`, 34 | expectedStatusCode: http.StatusCreated, 35 | expectedBodyContains: `{"name":"BUCKET-NAME","creationDate":"0001-01-01T00:00:00Z","bucketRegion":""}`, 36 | }, 37 | { 38 | it: "returns error for empty request", 39 | makeBucketFunc: func(context.Context, string, minio.MakeBucketOptions) error { 40 | return nil 41 | }, 42 | body: "", 43 | expectedStatusCode: http.StatusUnprocessableEntity, 44 | expectedBodyContains: http.StatusText(http.StatusUnprocessableEntity), 45 | }, 46 | { 47 | it: "returns error for malformed request", 48 | makeBucketFunc: func(context.Context, string, minio.MakeBucketOptions) error { 49 | return nil 50 | }, 51 | body: "}", 52 | expectedStatusCode: http.StatusUnprocessableEntity, 53 | expectedBodyContains: http.StatusText(http.StatusUnprocessableEntity), 54 | }, 55 | { 56 | it: "returns error if there is an S3 error", 57 | makeBucketFunc: func(context.Context, string, minio.MakeBucketOptions) error { 58 | return errS3 59 | }, 60 | body: `{"name":"BUCKET-NAME"}`, 61 | expectedStatusCode: http.StatusInternalServerError, 62 | expectedBodyContains: http.StatusText(http.StatusInternalServerError), 63 | }, 64 | } 65 | 66 | for _, tc := range cases { 67 | t.Run(tc.it, func(t *testing.T) { 68 | t.Parallel() 69 | is := is.New(t) 70 | 71 | s3 := &mocks.S3Mock{ 72 | MakeBucketFunc: tc.makeBucketFunc, 73 | } 74 | 75 | req, err := http.NewRequest(http.MethodPost, "/api/buckets", bytes.NewBufferString(tc.body)) 76 | is.NoErr(err) 77 | 78 | rr := httptest.NewRecorder() 79 | handler := s3manager.HandleCreateBucket(s3) 80 | 81 | handler.ServeHTTP(rr, req) 82 | resp := rr.Result() 83 | defer func() { 84 | err = resp.Body.Close() 85 | is.NoErr(err) 86 | }() 87 | body, err := io.ReadAll(resp.Body) 88 | is.NoErr(err) 89 | 90 | is.Equal(tc.expectedStatusCode, resp.StatusCode) // status code 91 | is.True(strings.Contains(string(body), tc.expectedBodyContains)) // body 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /internal/app/s3manager/bucket_view.go: -------------------------------------------------------------------------------- 1 | package s3manager 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "io/fs" 7 | "net/http" 8 | "path" 9 | "regexp" 10 | "strings" 11 | "time" 12 | 13 | "github.com/minio/minio-go/v7" 14 | ) 15 | 16 | // HandleBucketView shows the details page of a bucket. 17 | func HandleBucketView(s3 S3, templates fs.FS, allowDelete bool, listRecursive bool, rootURL string) http.HandlerFunc { 18 | type objectWithIcon struct { 19 | Key string 20 | Size int64 21 | LastModified time.Time 22 | Owner string 23 | Icon string 24 | IsFolder bool 25 | DisplayName string 26 | } 27 | 28 | type pageData struct { 29 | RootURL string 30 | BucketName string 31 | Objects []objectWithIcon 32 | AllowDelete bool 33 | Paths []string 34 | CurrentPath string 35 | } 36 | 37 | return func(w http.ResponseWriter, r *http.Request) { 38 | regex := regexp.MustCompile(`\/buckets\/([^\/]*)\/?(.*)`) 39 | matches := regex.FindStringSubmatch(r.RequestURI) 40 | bucketName := matches[1] 41 | path := matches[2] 42 | 43 | var objs []objectWithIcon 44 | opts := minio.ListObjectsOptions{ 45 | Recursive: listRecursive, 46 | Prefix: path, 47 | } 48 | objectCh := s3.ListObjects(r.Context(), bucketName, opts) 49 | for object := range objectCh { 50 | if object.Err != nil { 51 | handleHTTPError(w, fmt.Errorf("error listing objects: %w", object.Err)) 52 | return 53 | } 54 | 55 | obj := objectWithIcon{ 56 | Key: object.Key, 57 | Size: object.Size, 58 | LastModified: object.LastModified, 59 | Owner: object.Owner.DisplayName, 60 | Icon: icon(object.Key), 61 | IsFolder: strings.HasSuffix(object.Key, "/"), 62 | DisplayName: strings.TrimSuffix(strings.TrimPrefix(object.Key, path), "/"), 63 | } 64 | objs = append(objs, obj) 65 | } 66 | data := pageData{ 67 | RootURL: rootURL, 68 | BucketName: bucketName, 69 | Objects: objs, 70 | AllowDelete: allowDelete, 71 | Paths: removeEmptyStrings(strings.Split(path, "/")), 72 | CurrentPath: path, 73 | } 74 | 75 | t, err := template.ParseFS(templates, "layout.html.tmpl", "bucket.html.tmpl") 76 | if err != nil { 77 | handleHTTPError(w, fmt.Errorf("error parsing template files: %w", err)) 78 | return 79 | } 80 | err = t.ExecuteTemplate(w, "layout", data) 81 | if err != nil { 82 | handleHTTPError(w, fmt.Errorf("error executing template: %w", err)) 83 | return 84 | } 85 | } 86 | } 87 | 88 | // icon returns an icon for a file type. 89 | func icon(fileName string) string { 90 | if strings.HasSuffix(fileName, "/") { 91 | return "folder" 92 | } 93 | 94 | e := path.Ext(fileName) 95 | switch e { 96 | case ".tgz", ".gz", ".zip": 97 | return "archive" 98 | case ".png", ".jpg", ".gif", ".svg": 99 | return "photo" 100 | case ".mp3", ".wav": 101 | return "music_note" 102 | } 103 | 104 | return "insert_drive_file" 105 | } 106 | 107 | func removeEmptyStrings(input []string) []string { 108 | result := make([]string, 0, len(input)) 109 | for _, str := range input { 110 | if str == "" { 111 | continue 112 | } 113 | result = append(result, str) 114 | } 115 | return result 116 | } 117 | -------------------------------------------------------------------------------- /web/template/buckets.html.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "content" }} 2 | 12 | 13 |
14 |
15 |
16 | 17 | {{ if .HasError }} 18 |
19 |
20 |
21 |
22 |
23 | error 24 |
25 |
26 | Connection Error 27 |

{{ .ErrorMessage }}

28 | {{ if gt (len .S3Instances) 1 }} 29 |

Tip: Use the instance switcher button (bottom-left) to try a different S3 instance.

30 | {{ end }} 31 |
32 |
33 |
34 |
35 |
36 | {{ else if .Buckets }} 37 | {{ range $bucket := .Buckets }} 38 | 57 | {{ end }} 58 | {{ else }} 59 |

No buckets yet

60 | {{ end }} 61 | 62 |
63 |
64 |
65 | 66 | {{ if not .HasError }} 67 |
68 | 71 |
72 | {{ end }} 73 | 74 | 93 | 94 | 111 | {{ end }} 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # S3 Manager 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/cloudlena/s3manager)](https://goreportcard.com/report/github.com/cloudlena/s3manager) 4 | [![Build Status](https://github.com/cloudlena/s3manager/actions/workflows/main.yml/badge.svg)](https://github.com/cloudlena/s3manager/actions) 5 | 6 | A Web GUI written in Go to manage S3 buckets from any provider. 7 | 8 | ![Screenshot](https://raw.githubusercontent.com/cloudlena/s3manager/main/screenshot.png) 9 | 10 | ## Features 11 | 12 | - List all buckets in your account 13 | - Create a new bucket 14 | - List all objects in a bucket 15 | - Upload new objects to a bucket 16 | - Download object from a bucket 17 | - Delete an object in a bucket 18 | 19 | ## Usage 20 | 21 | ### Configuration 22 | 23 | The application can be configured with the following environment variables: 24 | 25 | - `ENDPOINT`: The endpoint of your S3 server (defaults to `s3.amazonaws.com`) 26 | - `REGION`: The region of your S3 server (defaults to `""`) 27 | - `ACCESS_KEY_ID`: Your S3 access key ID (required) (works only if `USE_IAM` is `false`) 28 | - `SECRET_ACCESS_KEY`: Your S3 secret access key (required) (works only if `USE_IAM` is `false`) 29 | - `USE_SSL`: Whether your S3 server uses SSL or not (defaults to `true`) 30 | - `SKIP_SSL_VERIFICATION`: Whether the HTTP client should skip SSL verification (defaults to `false`) 31 | - `SIGNATURE_TYPE`: The signature type to be used (defaults to `V4`; valid values are `V2, V4, V4Streaming, Anonymous`) 32 | - `PORT`: The port the app should listen on (defaults to `8080`) 33 | - `ALLOW_DELETE`: Enable buttons to delete objects (defaults to `true`) 34 | - `FORCE_DOWNLOAD`: Add response headers for object downloading instead of opening in a new tab (defaults to `true`) 35 | - `LIST_RECURSIVE`: List all objects in buckets recursively (defaults to `false`) 36 | - `USE_IAM`: Use IAM role instead of key pair (defaults to `false`) 37 | - `IAM_ENDPOINT`: Endpoint for IAM role retrieving (Can be blank for AWS) 38 | - `SSE_TYPE`: Specified server side encryption (defaults blank) Valid values can be `SSE`, `KMS`, `SSE-C` all others values don't enable the SSE 39 | - `SSE_KEY`: The key needed for SSE method (only for `KMS` and `SSE-C`) 40 | - `TIMEOUT`: The read and write timeout in seconds (default to `600` - 10 minutes) 41 | - `ROOT_URL`: A root URL prefix if running behind a reverse proxy (defaults to unset) 42 | 43 | ### Build and Run Locally 44 | 45 | 1. Run `make build` 46 | 1. Execute the created binary and visit 47 | 48 | ### Run Container image 49 | 50 | 1. Run `docker run -p 8080:8080 -e 'ACCESS_KEY_ID=XXX' -e 'SECRET_ACCESS_KEY=xxx' cloudlena/s3manager` 51 | 52 | ### Deploy to Kubernetes 53 | 54 | You can deploy S3 Manager to a Kubernetes cluster using the [Helm chart](https://github.com/sergeyshevch/s3manager-helm). 55 | 56 | #### Running behind a reverse proxy 57 | 58 | If there are multiple S3 users/accounts in a site then multiple instances of the S3 manager can be run in Kubernetes and expose behind a single nginx reverse proxy ingress. 59 | The s3manager can be run with a `ROOT_URL` environment variable set that accounts for the reverse proxy location. 60 | 61 | If the nginx configuration block looks like: 62 | 63 | ```nginx 64 | location /teamx/ { 65 | proxy_pass http://s3manager-teamx:8080/; 66 | auth_basic "teamx"; 67 | auth_basic_user_file /conf/teamx-htpasswd; 68 | } 69 | location /teamy/ { 70 | proxy_pass http://s3manager-teamy:8080/; 71 | 72 | } 73 | ``` 74 | 75 | Then the instance behind the `s3manager-teamx` service has `ROOT_URL=teamx` and the instance behind `s3manager-teamy` has `ROOT_URL=teamy`. 76 | Other nginx settings can be applied to each location. 77 | The nginx instance can be hosted on some reachable address and reverse proxy to the different S3 accounts. 78 | 79 | ## Development 80 | 81 | ### Lint Code 82 | 83 | 1. Run `make lint` 84 | 85 | ### Run Tests 86 | 87 | 1. Run `make test` 88 | 89 | ### Build Container Image 90 | 91 | The image is available on [Docker Hub](https://hub.docker.com/r/cloudlena/s3manager/). 92 | 93 | 1. Run `make build-image` 94 | 95 | ### Run Locally for Testing 96 | 97 | There is an example [docker-compose.yml](https://github.com/cloudlena/s3manager/blob/main/docker-compose.yml) file that spins up an S3 service and the S3 Manager. You can try it by issuing the following command: 98 | 99 | ```shell 100 | $ docker-compose up 101 | ``` 102 | 103 | ## GitHub Stars 104 | 105 | [![GitHub stars over time](https://starchart.cc/cloudlena/s3manager.svg?variant=adaptive)](https://starchart.cc/cloudlena/s3manager) 106 | -------------------------------------------------------------------------------- /web/template/layout.html.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "layout" }} 2 | 3 | 4 | 5 | 6 | S3 Manager 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 65 | 66 | 67 | 68 | 69 | {{ template "content" . }} 70 | 71 | 72 | {{ if and .S3Instances (gt (len .S3Instances) 1) }} 73 |
74 | 77 |
78 | {{ range .S3Instances }} 79 |
81 | {{ .Name }} 82 |
83 | {{ end }} 84 |
85 |
86 | {{ end }} 87 | 88 | 91 | 92 | 132 | 133 | 134 | 135 | {{ end }} 136 | -------------------------------------------------------------------------------- /internal/app/s3manager/multi_s3_manager.go: -------------------------------------------------------------------------------- 1 | package s3manager 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "sync" 9 | 10 | "github.com/minio/minio-go/v7" 11 | "github.com/minio/minio-go/v7/pkg/credentials" 12 | ) 13 | 14 | // S3Instance represents a configured S3 instance 15 | type S3Instance struct { 16 | ID string 17 | Name string 18 | Client S3 19 | } 20 | 21 | // MultiS3Manager manages multiple S3 instances 22 | type MultiS3Manager struct { 23 | instances map[string]*S3Instance 24 | currentID string 25 | instanceOrder []string 26 | mu sync.RWMutex 27 | } 28 | 29 | // S3InstanceConfig holds configuration for a single S3 instance 30 | type S3InstanceConfig struct { 31 | Name string 32 | Endpoint string 33 | UseIam bool 34 | IamEndpoint string 35 | AccessKeyID string 36 | SecretAccessKey string 37 | Region string 38 | UseSSL bool 39 | SkipSSLVerification bool 40 | SignatureType string 41 | } 42 | 43 | // NewMultiS3Manager creates a new MultiS3Manager with the given configurations 44 | func NewMultiS3Manager(configs []S3InstanceConfig) (*MultiS3Manager, error) { 45 | manager := &MultiS3Manager{ 46 | instances: make(map[string]*S3Instance), 47 | instanceOrder: make([]string, 0, len(configs)), 48 | } 49 | 50 | for i, config := range configs { 51 | instanceID := fmt.Sprintf("%d", i+1) 52 | 53 | // Set up S3 client options 54 | opts := &minio.Options{ 55 | Secure: config.UseSSL, 56 | } 57 | 58 | if config.UseIam { 59 | opts.Creds = credentials.NewIAM(config.IamEndpoint) 60 | } else { 61 | var signatureType credentials.SignatureType 62 | 63 | switch config.SignatureType { 64 | case "V2": 65 | signatureType = credentials.SignatureV2 66 | case "V4": 67 | signatureType = credentials.SignatureV4 68 | case "V4Streaming": 69 | signatureType = credentials.SignatureV4Streaming 70 | case "Anonymous": 71 | signatureType = credentials.SignatureAnonymous 72 | default: 73 | return nil, fmt.Errorf("invalid SIGNATURE_TYPE: %s", config.SignatureType) 74 | } 75 | 76 | opts.Creds = credentials.NewStatic(config.AccessKeyID, config.SecretAccessKey, "", signatureType) 77 | } 78 | 79 | if config.Region != "" { 80 | opts.Region = config.Region 81 | } 82 | 83 | if config.UseSSL && config.SkipSSLVerification { 84 | opts.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec 85 | } 86 | 87 | // Create S3 client 88 | s3Client, err := minio.New(config.Endpoint, opts) 89 | if err != nil { 90 | return nil, fmt.Errorf("error creating s3 client for instance %s: %w", config.Name, err) 91 | } 92 | 93 | instance := &S3Instance{ 94 | ID: instanceID, 95 | Name: config.Name, 96 | Client: s3Client, 97 | } 98 | 99 | manager.instances[instanceID] = instance 100 | manager.instanceOrder = append(manager.instanceOrder, instanceID) 101 | 102 | // Set the first instance as current 103 | if i == 0 { 104 | manager.currentID = instanceID 105 | } 106 | } 107 | 108 | if len(manager.instances) == 0 { 109 | return nil, fmt.Errorf("no S3 instances configured") 110 | } 111 | 112 | log.Printf("Initialized MultiS3Manager with %d instances", len(manager.instances)) 113 | return manager, nil 114 | } 115 | 116 | // GetCurrentClient returns the currently active S3 client 117 | func (m *MultiS3Manager) GetCurrentClient() S3 { 118 | m.mu.RLock() 119 | defer m.mu.RUnlock() 120 | 121 | instance, exists := m.instances[m.currentID] 122 | if !exists && len(m.instances) > 0 { 123 | // Fallback to first instance if current doesn't exist 124 | m.currentID = m.instanceOrder[0] 125 | instance = m.instances[m.currentID] 126 | } 127 | return instance.Client 128 | } 129 | 130 | // GetCurrentInstance returns the currently active S3 instance info 131 | func (m *MultiS3Manager) GetCurrentInstance() *S3Instance { 132 | m.mu.RLock() 133 | defer m.mu.RUnlock() 134 | 135 | instance, exists := m.instances[m.currentID] 136 | if !exists && len(m.instances) > 0 { 137 | // Fallback to first instance if current doesn't exist 138 | m.currentID = m.instanceOrder[0] 139 | instance = m.instances[m.currentID] 140 | } 141 | return instance 142 | } 143 | 144 | // SetCurrentInstance switches to the specified S3 instance 145 | func (m *MultiS3Manager) SetCurrentInstance(instanceID string) error { 146 | m.mu.Lock() 147 | defer m.mu.Unlock() 148 | 149 | if _, exists := m.instances[instanceID]; !exists { 150 | return fmt.Errorf("S3 instance with ID %s not found", instanceID) 151 | } 152 | 153 | m.currentID = instanceID 154 | log.Printf("Switched to S3 instance: %s (%s)", instanceID, m.instances[instanceID].Name) 155 | return nil 156 | } 157 | 158 | // GetAllInstances returns all available S3 instances 159 | func (m *MultiS3Manager) GetAllInstances() []*S3Instance { 160 | m.mu.RLock() 161 | defer m.mu.RUnlock() 162 | 163 | instances := make([]*S3Instance, 0, len(m.instanceOrder)) 164 | for _, id := range m.instanceOrder { 165 | instances = append(instances, m.instances[id]) 166 | } 167 | return instances 168 | } -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cloudlena/adapters v0.0.0-20251206101801-0ca4d9e6fb6b h1:p7AwWpdhhYvan+4LR0jgatsubKhyPClagBMyhei0AT8= 2 | github.com/cloudlena/adapters v0.0.0-20251206101801-0ca4d9e6fb6b/go.mod h1:cIED1HeFSjKO8ObsFULtst7jMypyWpxxcPZCPR2AcmE= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 6 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 7 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 8 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 9 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 10 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 11 | github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= 12 | github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= 13 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 14 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 15 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 16 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 17 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 18 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 19 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 20 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 21 | github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= 22 | github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 23 | github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 24 | github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 25 | github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 26 | github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= 27 | github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= 28 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 29 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 30 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 31 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 32 | github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= 33 | github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 34 | github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= 35 | github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= 36 | github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= 37 | github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= 38 | github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ= 39 | github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk= 40 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 41 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 42 | github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= 43 | github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= 44 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 45 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 46 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 47 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 48 | github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= 49 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 50 | github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= 51 | github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= 52 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= 53 | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= 54 | github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= 55 | github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 56 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 57 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 58 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= 59 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= 60 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 61 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 62 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 63 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 64 | github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= 65 | github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= 66 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 67 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 68 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 69 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 70 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 71 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 72 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 73 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 74 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 75 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 76 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 77 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 78 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 79 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 80 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 81 | -------------------------------------------------------------------------------- /internal/app/s3manager/bucket_view_test.go: -------------------------------------------------------------------------------- 1 | package s3manager_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "testing" 13 | 14 | "github.com/cloudlena/s3manager/internal/app/s3manager" 15 | "github.com/cloudlena/s3manager/internal/app/s3manager/mocks" 16 | "github.com/gorilla/mux" 17 | "github.com/matryer/is" 18 | "github.com/minio/minio-go/v7" 19 | ) 20 | 21 | func TestHandleBucketView(t *testing.T) { 22 | t.Parallel() 23 | 24 | cases := []struct { 25 | it string 26 | listObjectsFunc func(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo 27 | bucketName string 28 | rootUrl string 29 | path string 30 | expectedStatusCode int 31 | expectedBodyContains string 32 | }{ 33 | { 34 | it: "renders a bucket containing a file", 35 | listObjectsFunc: func(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo { 36 | objCh := make(chan minio.ObjectInfo) 37 | go func() { 38 | objCh <- minio.ObjectInfo{Key: "FILE-NAME"} 39 | close(objCh) 40 | }() 41 | return objCh 42 | }, 43 | bucketName: "BUCKET-NAME", 44 | expectedStatusCode: http.StatusOK, 45 | expectedBodyContains: "FILE-NAME", 46 | }, 47 | { 48 | it: "renders placeholder for an empty bucket", 49 | listObjectsFunc: func(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo { 50 | objCh := make(chan minio.ObjectInfo) 51 | close(objCh) 52 | return objCh 53 | }, 54 | bucketName: "BUCKET-NAME", 55 | expectedStatusCode: http.StatusOK, 56 | expectedBodyContains: "No objects in", 57 | }, 58 | { 59 | it: "renders a bucket containing an archive", 60 | listObjectsFunc: func(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo { 61 | objCh := make(chan minio.ObjectInfo) 62 | go func() { 63 | objCh <- minio.ObjectInfo{Key: "archive.tar.gz"} 64 | close(objCh) 65 | }() 66 | return objCh 67 | }, 68 | bucketName: "BUCKET-NAME", 69 | expectedStatusCode: http.StatusOK, 70 | expectedBodyContains: "archive", 71 | }, 72 | { 73 | it: "renders a bucket containing an image", 74 | listObjectsFunc: func(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo { 75 | objCh := make(chan minio.ObjectInfo) 76 | go func() { 77 | objCh <- minio.ObjectInfo{Key: "FILE-NAME.png"} 78 | close(objCh) 79 | }() 80 | return objCh 81 | }, 82 | bucketName: "BUCKET-NAME", 83 | expectedStatusCode: http.StatusOK, 84 | expectedBodyContains: "photo", 85 | }, 86 | { 87 | it: "renders a bucket containing a sound file", 88 | listObjectsFunc: func(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo { 89 | objCh := make(chan minio.ObjectInfo) 90 | go func() { 91 | objCh <- minio.ObjectInfo{Key: "FILE-NAME.mp3"} 92 | close(objCh) 93 | }() 94 | return objCh 95 | }, 96 | bucketName: "BUCKET-NAME", 97 | expectedStatusCode: http.StatusOK, 98 | expectedBodyContains: "music_note", 99 | }, 100 | { 101 | it: "returns error if the bucket doesn't exist", 102 | listObjectsFunc: func(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo { 103 | objCh := make(chan minio.ObjectInfo) 104 | go func() { 105 | objCh <- minio.ObjectInfo{Err: errBucketDoesNotExist} 106 | close(objCh) 107 | }() 108 | return objCh 109 | }, 110 | bucketName: "BUCKET-NAME", 111 | expectedStatusCode: http.StatusNotFound, 112 | expectedBodyContains: http.StatusText(http.StatusNotFound), 113 | }, 114 | { 115 | it: "returns error if there is an S3 error", 116 | listObjectsFunc: func(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo { 117 | objCh := make(chan minio.ObjectInfo) 118 | go func() { 119 | objCh <- minio.ObjectInfo{Err: errS3} 120 | close(objCh) 121 | }() 122 | return objCh 123 | }, 124 | bucketName: "BUCKET-NAME", 125 | expectedStatusCode: http.StatusInternalServerError, 126 | expectedBodyContains: http.StatusText(http.StatusInternalServerError), 127 | }, 128 | { 129 | it: "renders a bucket with folder", 130 | listObjectsFunc: func(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo { 131 | objCh := make(chan minio.ObjectInfo) 132 | go func() { 133 | objCh <- minio.ObjectInfo{Key: "AFolder/"} 134 | close(objCh) 135 | }() 136 | return objCh 137 | }, 138 | bucketName: "BUCKET-NAME", 139 | expectedStatusCode: http.StatusOK, 140 | expectedBodyContains: "folder", 141 | }, 142 | { 143 | it: "renders a bucket with path", 144 | listObjectsFunc: func(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo { 145 | objCh := make(chan minio.ObjectInfo) 146 | close(objCh) 147 | return objCh 148 | }, 149 | bucketName: "BUCKET-NAME", 150 | path: "abc/def", 151 | expectedStatusCode: http.StatusOK, 152 | expectedBodyContains: "def", 153 | }, 154 | { 155 | it: "renders a bucket with path", 156 | listObjectsFunc: func(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo { 157 | objCh := make(chan minio.ObjectInfo) 158 | close(objCh) 159 | return objCh 160 | }, 161 | bucketName: "BUCKET-NAME", 162 | path: "abc/def", 163 | expectedStatusCode: http.StatusOK, 164 | expectedBodyContains: "def", 165 | }, 166 | { 167 | it: "setting rootUrl works", 168 | listObjectsFunc: func(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo { 169 | objCh := make(chan minio.ObjectInfo) 170 | close(objCh) 171 | return objCh 172 | }, 173 | bucketName: "BUCKET-NAME", 174 | path: "abc/def", 175 | rootUrl: "rootTest", 176 | expectedStatusCode: http.StatusOK, 177 | expectedBodyContains: "def", 178 | }, 179 | } 180 | 181 | for _, tc := range cases { 182 | t.Run(tc.it, func(t *testing.T) { 183 | t.Parallel() 184 | is := is.New(t) 185 | 186 | s3 := &mocks.S3Mock{ 187 | ListObjectsFunc: tc.listObjectsFunc, 188 | } 189 | 190 | templates := os.DirFS(filepath.Join("..", "..", "..", "web", "template")) 191 | r := mux.NewRouter() 192 | r.PathPrefix("/buckets/").Handler(s3manager.HandleBucketView(s3, templates, true, true, tc.rootUrl)).Methods(http.MethodGet) 193 | 194 | ts := httptest.NewServer(r) 195 | defer ts.Close() 196 | 197 | resp, err := http.Get(fmt.Sprintf("%s/buckets/%s/%s", ts.URL, tc.bucketName, tc.path)) 198 | is.NoErr(err) 199 | defer func() { 200 | err = resp.Body.Close() 201 | is.NoErr(err) 202 | }() 203 | body, err := io.ReadAll(resp.Body) 204 | is.NoErr(err) 205 | 206 | is.Equal(tc.expectedStatusCode, resp.StatusCode) // status code 207 | is.True(strings.Contains(string(body), tc.expectedBodyContains)) // body 208 | 209 | // fmt.Println(string(body)) 210 | if tc.expectedStatusCode == http.StatusOK { 211 | hyperlink := fmt.Sprintf("arrow_back buckets ", tc.rootUrl) 212 | is.True(strings.Contains(string(body), hyperlink)) 213 | } 214 | }) 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "io/fs" 7 | "log" 8 | "net/http" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | "github.com/cloudlena/adapters/logging" 14 | "github.com/cloudlena/s3manager/internal/app/s3manager" 15 | "github.com/gorilla/mux" 16 | "github.com/spf13/viper" 17 | ) 18 | 19 | //go:embed web/template 20 | var templateFS embed.FS 21 | 22 | //go:embed web/static 23 | var staticFS embed.FS 24 | 25 | type s3InstanceConfig struct { 26 | Name string 27 | Endpoint string 28 | UseIam bool 29 | IamEndpoint string 30 | AccessKeyID string 31 | SecretAccessKey string 32 | Region string 33 | UseSSL bool 34 | SkipSSLVerification bool 35 | SignatureType string 36 | } 37 | 38 | type configuration struct { 39 | S3Instances []s3InstanceConfig 40 | AllowDelete bool 41 | ForceDownload bool 42 | ListRecursive bool 43 | Port string 44 | Timeout int32 45 | SseType string 46 | SseKey string 47 | } 48 | 49 | func parseConfiguration() configuration { 50 | viper.AutomaticEnv() 51 | 52 | // Parse S3 instances from numbered environment variables 53 | var s3Instances []s3InstanceConfig 54 | for i := 1; ; i++ { 55 | prefix := fmt.Sprintf("%d_", i) 56 | name := viper.GetString(prefix + "NAME") 57 | endpoint := viper.GetString(prefix + "ENDPOINT") 58 | 59 | // If NAME or ENDPOINT is not found, stop parsing 60 | if name == "" || endpoint == "" { 61 | break 62 | } 63 | 64 | accessKeyID := viper.GetString(prefix + "ACCESS_KEY_ID") 65 | secretAccessKey := viper.GetString(prefix + "SECRET_ACCESS_KEY") 66 | useIam := viper.GetBool(prefix + "USE_IAM") 67 | iamEndpoint := viper.GetString(prefix + "IAM_ENDPOINT") 68 | region := viper.GetString(prefix + "REGION") 69 | 70 | viper.SetDefault(prefix+"USE_SSL", true) 71 | useSSL := viper.GetBool(prefix + "USE_SSL") 72 | 73 | viper.SetDefault(prefix+"SKIP_SSL_VERIFICATION", false) 74 | skipSSLVerification := viper.GetBool(prefix + "SKIP_SSL_VERIFICATION") 75 | 76 | viper.SetDefault(prefix+"SIGNATURE_TYPE", "V4") 77 | signatureType := viper.GetString(prefix + "SIGNATURE_TYPE") 78 | 79 | if !useIam { 80 | if accessKeyID == "" { 81 | log.Fatalf("please provide %sACCESS_KEY_ID for instance %s", prefix, name) 82 | } 83 | if secretAccessKey == "" { 84 | log.Fatalf("please provide %sSECRET_ACCESS_KEY for instance %s", prefix, name) 85 | } 86 | } 87 | 88 | s3Instances = append(s3Instances, s3InstanceConfig{ 89 | Name: name, 90 | Endpoint: endpoint, 91 | UseIam: useIam, 92 | IamEndpoint: iamEndpoint, 93 | AccessKeyID: accessKeyID, 94 | SecretAccessKey: secretAccessKey, 95 | Region: region, 96 | UseSSL: useSSL, 97 | SkipSSLVerification: skipSSLVerification, 98 | SignatureType: signatureType, 99 | }) 100 | } 101 | 102 | if len(s3Instances) == 0 { 103 | log.Fatal("no S3 instances configured. Please provide numbered environment variables like 1_NAME, 1_ENDPOINT, etc.") 104 | } 105 | 106 | viper.SetDefault("ALLOW_DELETE", true) 107 | allowDelete := viper.GetBool("ALLOW_DELETE") 108 | 109 | viper.SetDefault("FORCE_DOWNLOAD", true) 110 | forceDownload := viper.GetBool("FORCE_DOWNLOAD") 111 | 112 | listRecursive := viper.GetBool("LIST_RECURSIVE") 113 | 114 | viper.SetDefault("PORT", "8080") 115 | port := viper.GetString("PORT") 116 | 117 | viper.SetDefault("TIMEOUT", 600) 118 | timeout := viper.GetInt32("TIMEOUT") 119 | 120 | viper.SetDefault("SSE_TYPE", "") 121 | sseType := viper.GetString("SSE_TYPE") 122 | 123 | viper.SetDefault("SSE_KEY", "") 124 | sseKey := viper.GetString("SSE_KEY") 125 | 126 | return configuration{ 127 | S3Instances: s3Instances, 128 | AllowDelete: allowDelete, 129 | ForceDownload: forceDownload, 130 | ListRecursive: listRecursive, 131 | Port: port, 132 | Timeout: timeout, 133 | SseType: sseType, 134 | SseKey: sseKey, 135 | } 136 | } 137 | 138 | func main() { 139 | configuration := parseConfiguration() 140 | 141 | sseType := s3manager.SSEType{Type: configuration.SseType, Key: configuration.SseKey} 142 | serverTimeout := time.Duration(configuration.Timeout) * time.Second 143 | 144 | // Set up templates 145 | templates, err := fs.Sub(templateFS, "web/template") 146 | if err != nil { 147 | log.Fatal(err) 148 | } 149 | // Set up statics 150 | statics, err := fs.Sub(staticFS, "web/static") 151 | if err != nil { 152 | log.Fatal(err) 153 | } 154 | 155 | // Convert configuration to s3manager format 156 | var s3Configs []s3manager.S3InstanceConfig 157 | for _, instance := range configuration.S3Instances { 158 | s3Configs = append(s3Configs, s3manager.S3InstanceConfig{ 159 | Name: instance.Name, 160 | Endpoint: instance.Endpoint, 161 | UseIam: instance.UseIam, 162 | IamEndpoint: instance.IamEndpoint, 163 | AccessKeyID: instance.AccessKeyID, 164 | SecretAccessKey: instance.SecretAccessKey, 165 | Region: instance.Region, 166 | UseSSL: instance.UseSSL, 167 | SkipSSLVerification: instance.SkipSSLVerification, 168 | SignatureType: instance.SignatureType, 169 | }) 170 | } 171 | 172 | // Set up Multi S3 Manager 173 | s3Manager, err := s3manager.NewMultiS3Manager(s3Configs) 174 | if err != nil { 175 | log.Fatalln(fmt.Errorf("error creating multi s3 manager: %w", err)) 176 | } 177 | 178 | // Check for a root URL to insert into HTML templates in case of reverse proxying 179 | rootURL, rootSet := os.LookupEnv("ROOT_URL") 180 | if rootSet && !strings.HasPrefix(rootURL, "/") { 181 | rootURL = "/" + rootURL 182 | } 183 | 184 | // Set up router 185 | r := mux.NewRouter() 186 | r.Handle("/", http.RedirectHandler("/buckets", http.StatusPermanentRedirect)).Methods(http.MethodGet) 187 | r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.FS(statics)))).Methods(http.MethodGet) 188 | 189 | // S3 instance management endpoints 190 | r.Handle("/api/s3-instances", s3manager.HandleGetS3Instances(s3Manager)).Methods(http.MethodGet) 191 | r.Handle("/api/s3-instances/{instanceId}/switch", s3manager.HandleSwitchS3Instance(s3Manager)).Methods(http.MethodPost) 192 | 193 | // S3 management endpoints (using current instance) 194 | r.Handle("/buckets", s3manager.HandleBucketsViewWithManager(s3Manager, templates, configuration.AllowDelete, rootURL)).Methods(http.MethodGet) 195 | r.PathPrefix("/buckets/").Handler(s3manager.HandleBucketViewWithManager(s3Manager, templates, configuration.AllowDelete, configuration.ListRecursive, rootURL)).Methods(http.MethodGet) 196 | r.Handle("/api/buckets", s3manager.HandleCreateBucketWithManager(s3Manager)).Methods(http.MethodPost) 197 | if configuration.AllowDelete { 198 | r.Handle("/api/buckets/{bucketName}", s3manager.HandleDeleteBucketWithManager(s3Manager)).Methods(http.MethodDelete) 199 | } 200 | r.Handle("/api/buckets/{bucketName}/objects", s3manager.HandleCreateObjectWithManager(s3Manager, sseType)).Methods(http.MethodPost) 201 | r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}/url", s3manager.HandleGenerateURLWithManager(s3Manager)).Methods(http.MethodGet) 202 | r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}", s3manager.HandleGetObjectWithManager(s3Manager, configuration.ForceDownload)).Methods(http.MethodGet) 203 | if configuration.AllowDelete { 204 | r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}", s3manager.HandleDeleteObjectWithManager(s3Manager)).Methods(http.MethodDelete) 205 | } 206 | 207 | lr := logging.Handler(os.Stdout)(r) 208 | srv := &http.Server{ 209 | Addr: ":" + configuration.Port, 210 | Handler: lr, 211 | ReadTimeout: serverTimeout, 212 | WriteTimeout: serverTimeout, 213 | } 214 | log.Fatal(srv.ListenAndServe()) 215 | } 216 | -------------------------------------------------------------------------------- /internal/app/s3manager/manager_handlers.go: -------------------------------------------------------------------------------- 1 | package s3manager 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "io/fs" 7 | "net/http" 8 | "regexp" 9 | "strings" 10 | "time" 11 | 12 | "github.com/minio/minio-go/v7" 13 | ) 14 | 15 | // HandleBucketsViewWithManager renders all buckets on an HTML page using MultiS3Manager. 16 | func HandleBucketsViewWithManager(manager *MultiS3Manager, templates fs.FS, allowDelete bool, rootURL string) http.HandlerFunc { 17 | type pageData struct { 18 | RootURL string 19 | Buckets []interface{} 20 | AllowDelete bool 21 | CurrentS3 *S3Instance 22 | S3Instances []*S3Instance 23 | HasError bool 24 | ErrorMessage string 25 | } 26 | 27 | return func(w http.ResponseWriter, r *http.Request) { 28 | s3 := manager.GetCurrentClient() 29 | current := manager.GetCurrentInstance() 30 | instances := manager.GetAllInstances() 31 | 32 | buckets, err := s3.ListBuckets(r.Context()) 33 | 34 | data := pageData{ 35 | RootURL: rootURL, 36 | AllowDelete: allowDelete, 37 | CurrentS3: current, 38 | S3Instances: instances, 39 | HasError: false, 40 | } 41 | 42 | if err != nil { 43 | // Instead of returning an HTTP error, show a user-friendly message 44 | data.HasError = true 45 | data.ErrorMessage = fmt.Sprintf("Unable to connect to S3 instance '%s'. Please check the credentials and try switching to another instance.", current.Name) 46 | data.Buckets = make([]interface{}, 0) // Empty buckets list 47 | } else { 48 | data.Buckets = make([]interface{}, len(buckets)) 49 | for i, bucket := range buckets { 50 | data.Buckets[i] = bucket 51 | } 52 | } 53 | 54 | t, err := template.ParseFS(templates, "layout.html.tmpl", "buckets.html.tmpl") 55 | if err != nil { 56 | handleHTTPError(w, err) 57 | return 58 | } 59 | err = t.ExecuteTemplate(w, "layout", data) 60 | if err != nil { 61 | handleHTTPError(w, err) 62 | return 63 | } 64 | } 65 | } 66 | 67 | // HandleBucketViewWithManager shows the details page of a bucket using MultiS3Manager. 68 | func HandleBucketViewWithManager(manager *MultiS3Manager, templates fs.FS, allowDelete bool, listRecursive bool, rootURL string) http.HandlerFunc { 69 | return func(w http.ResponseWriter, r *http.Request) { 70 | s3 := manager.GetCurrentClient() 71 | current := manager.GetCurrentInstance() 72 | instances := manager.GetAllInstances() 73 | 74 | // Create a modified handler that includes S3 instance data 75 | handler := createBucketViewWithS3Data(s3, templates, allowDelete, listRecursive, rootURL, current, instances) 76 | handler(w, r) 77 | } 78 | } 79 | 80 | // HandleCreateBucketWithManager creates a new bucket using MultiS3Manager. 81 | func HandleCreateBucketWithManager(manager *MultiS3Manager) http.HandlerFunc { 82 | return func(w http.ResponseWriter, r *http.Request) { 83 | s3 := manager.GetCurrentClient() 84 | // Delegate to the original handler with the current S3 client 85 | handler := HandleCreateBucket(s3) 86 | handler(w, r) 87 | } 88 | } 89 | 90 | // HandleDeleteBucketWithManager deletes a bucket using MultiS3Manager. 91 | func HandleDeleteBucketWithManager(manager *MultiS3Manager) http.HandlerFunc { 92 | return func(w http.ResponseWriter, r *http.Request) { 93 | s3 := manager.GetCurrentClient() 94 | // Delegate to the original handler with the current S3 client 95 | handler := HandleDeleteBucket(s3) 96 | handler(w, r) 97 | } 98 | } 99 | 100 | // HandleCreateObjectWithManager uploads a new object using MultiS3Manager. 101 | func HandleCreateObjectWithManager(manager *MultiS3Manager, sseInfo SSEType) http.HandlerFunc { 102 | return func(w http.ResponseWriter, r *http.Request) { 103 | s3 := manager.GetCurrentClient() 104 | // Delegate to the original handler with the current S3 client 105 | handler := HandleCreateObject(s3, sseInfo) 106 | handler(w, r) 107 | } 108 | } 109 | 110 | // HandleGenerateURLWithManager generates a presigned URL using MultiS3Manager. 111 | func HandleGenerateURLWithManager(manager *MultiS3Manager) http.HandlerFunc { 112 | return func(w http.ResponseWriter, r *http.Request) { 113 | s3 := manager.GetCurrentClient() 114 | // Delegate to the original handler with the current S3 client 115 | handler := HandleGenerateURL(s3) 116 | handler(w, r) 117 | } 118 | } 119 | 120 | // HandleGetObjectWithManager downloads an object to the client using MultiS3Manager. 121 | func HandleGetObjectWithManager(manager *MultiS3Manager, forceDownload bool) http.HandlerFunc { 122 | return func(w http.ResponseWriter, r *http.Request) { 123 | s3 := manager.GetCurrentClient() 124 | // Delegate to the original handler with the current S3 client 125 | handler := HandleGetObject(s3, forceDownload) 126 | handler(w, r) 127 | } 128 | } 129 | 130 | // HandleDeleteObjectWithManager deletes an object using MultiS3Manager. 131 | func HandleDeleteObjectWithManager(manager *MultiS3Manager) http.HandlerFunc { 132 | return func(w http.ResponseWriter, r *http.Request) { 133 | s3 := manager.GetCurrentClient() 134 | // Delegate to the original handler with the current S3 client 135 | handler := HandleDeleteObject(s3) 136 | handler(w, r) 137 | } 138 | } 139 | 140 | // createBucketViewWithS3Data creates a bucket view handler that includes S3 instance data 141 | func createBucketViewWithS3Data(s3 S3, templates fs.FS, allowDelete bool, listRecursive bool, rootURL string, current *S3Instance, instances []*S3Instance) http.HandlerFunc { 142 | type objectWithIcon struct { 143 | Key string 144 | Size int64 145 | LastModified time.Time 146 | Owner string 147 | Icon string 148 | IsFolder bool 149 | DisplayName string 150 | } 151 | 152 | type pageData struct { 153 | RootURL string 154 | BucketName string 155 | Objects []objectWithIcon 156 | AllowDelete bool 157 | Paths []string 158 | CurrentPath string 159 | CurrentS3 *S3Instance 160 | S3Instances []*S3Instance 161 | HasError bool 162 | ErrorMessage string 163 | } 164 | 165 | return func(w http.ResponseWriter, r *http.Request) { 166 | regex := regexp.MustCompile(`\/buckets\/([^\/]*)\/?(.*)`) 167 | matches := regex.FindStringSubmatch(r.RequestURI) 168 | bucketName := matches[1] 169 | path := matches[2] 170 | 171 | var objs []objectWithIcon 172 | hasError := false 173 | errorMessage := "" 174 | 175 | opts := minio.ListObjectsOptions{ 176 | Recursive: listRecursive, 177 | Prefix: path, 178 | } 179 | objectCh := s3.ListObjects(r.Context(), bucketName, opts) 180 | for object := range objectCh { 181 | if object.Err != nil { 182 | // Instead of returning HTTP error, show user-friendly message 183 | hasError = true 184 | if strings.Contains(object.Err.Error(), "AccessDenied") || strings.Contains(object.Err.Error(), "InvalidAccessKeyId") || strings.Contains(object.Err.Error(), "SignatureDoesNotMatch") { 185 | errorMessage = fmt.Sprintf("Unable to access bucket '%s' on S3 instance '%s'. Please check the credentials and try switching to another instance.", bucketName, current.Name) 186 | } else if strings.Contains(object.Err.Error(), ErrBucketDoesNotExist) { 187 | errorMessage = fmt.Sprintf("Bucket '%s' does not exist on S3 instance '%s'. Please try switching to another instance or go back to the buckets list.", bucketName, current.Name) 188 | } else { 189 | errorMessage = fmt.Sprintf("Unable to list objects in bucket '%s' on S3 instance '%s'. Please try switching to another instance.", bucketName, current.Name) 190 | } 191 | break 192 | } 193 | 194 | obj := objectWithIcon{ 195 | Key: object.Key, 196 | Size: object.Size, 197 | LastModified: object.LastModified, 198 | Owner: object.Owner.DisplayName, 199 | Icon: icon(object.Key), 200 | IsFolder: strings.HasSuffix(object.Key, "/"), 201 | DisplayName: strings.TrimSuffix(strings.TrimPrefix(object.Key, path), "/"), 202 | } 203 | objs = append(objs, obj) 204 | } 205 | 206 | data := pageData{ 207 | RootURL: rootURL, 208 | BucketName: bucketName, 209 | Objects: objs, 210 | AllowDelete: allowDelete, 211 | Paths: removeEmptyStrings(strings.Split(path, "/")), 212 | CurrentPath: path, 213 | CurrentS3: current, 214 | S3Instances: instances, 215 | HasError: hasError, 216 | ErrorMessage: errorMessage, 217 | } 218 | 219 | t, err := template.ParseFS(templates, "layout.html.tmpl", "bucket.html.tmpl") 220 | if err != nil { 221 | handleHTTPError(w, fmt.Errorf("error parsing template files: %w", err)) 222 | return 223 | } 224 | err = t.ExecuteTemplate(w, "layout", data) 225 | if err != nil { 226 | handleHTTPError(w, fmt.Errorf("error executing template: %w", err)) 227 | return 228 | } 229 | } 230 | } -------------------------------------------------------------------------------- /web/template/bucket.html.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "content" }} 2 | 13 | 14 | 45 | 46 |
47 | {{ if .HasError }} 48 |
49 |
50 |
51 |
52 |
53 |
54 | error 55 |
56 |
57 | Connection Error 58 |

{{ .ErrorMessage }}

59 | {{ if gt (len .S3Instances) 1 }} 60 |

Tip: Use the instance switcher button (bottom-left) to try a different S3 instance.

61 | {{ end }} 62 | 67 |
68 |
69 |
70 |
71 |
72 |
73 | {{ else if .Objects }} 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | {{ range $index, $object := .Objects }} 87 | 88 | 95 | 96 | 97 | 98 | 113 | 114 | {{ end }} 115 | 116 |
KeySizeOwnerLast Modified
93 | {{ $object.Icon }} {{ $object.DisplayName }} 94 | {{ $object.Size }} bytes{{ $object.Owner }}{{ $object.LastModified }} 99 | {{ if not $object.IsFolder }} 100 | 103 | 104 | 111 | {{ end }} 112 |
117 | {{ else }} 118 |

No objects in {{ .BucketName }}/{{ .CurrentPath }} yet

119 | {{ end }} 120 | 121 |
122 | 131 |
132 |
133 | 134 | {{ if not .HasError }} 135 |
136 | 139 | 140 | 143 | 144 | 147 |
148 | {{ end }} 149 | 150 | 151 | 152 | 153 | 173 | 174 | 221 | 222 | 373 | {{ end }} 374 | -------------------------------------------------------------------------------- /internal/app/s3manager/mocks/s3.go: -------------------------------------------------------------------------------- 1 | // Code generated by moq; DO NOT EDIT. 2 | // github.com/matryer/moq 3 | 4 | package mocks 5 | 6 | import ( 7 | "context" 8 | "github.com/cloudlena/s3manager/internal/app/s3manager" 9 | "github.com/minio/minio-go/v7" 10 | "io" 11 | "net/url" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | // Ensure, that S3Mock does implement s3manager.S3. 17 | // If this is not the case, regenerate this file with moq. 18 | var _ s3manager.S3 = &S3Mock{} 19 | 20 | // S3Mock is a mock implementation of s3manager.S3. 21 | // 22 | // func TestSomethingThatUsesS3(t *testing.T) { 23 | // 24 | // // make and configure a mocked s3manager.S3 25 | // mockedS3 := &S3Mock{ 26 | // GetObjectFunc: func(ctx context.Context, bucketName string, objectName string, opts minio.GetObjectOptions) (*minio.Object, error) { 27 | // panic("mock out the GetObject method") 28 | // }, 29 | // ListBucketsFunc: func(ctx context.Context) ([]minio.BucketInfo, error) { 30 | // panic("mock out the ListBuckets method") 31 | // }, 32 | // ListObjectsFunc: func(ctx context.Context, bucketName string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo { 33 | // panic("mock out the ListObjects method") 34 | // }, 35 | // MakeBucketFunc: func(ctx context.Context, bucketName string, opts minio.MakeBucketOptions) error { 36 | // panic("mock out the MakeBucket method") 37 | // }, 38 | // PresignedGetObjectFunc: func(ctx context.Context, bucketName string, objectName string, expiry time.Duration, reqParams url.Values) (*url.URL, error) { 39 | // panic("mock out the PresignedGetObject method") 40 | // }, 41 | // PutObjectFunc: func(ctx context.Context, bucketName string, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (minio.UploadInfo, error) { 42 | // panic("mock out the PutObject method") 43 | // }, 44 | // RemoveBucketFunc: func(ctx context.Context, bucketName string) error { 45 | // panic("mock out the RemoveBucket method") 46 | // }, 47 | // RemoveObjectFunc: func(ctx context.Context, bucketName string, objectName string, opts minio.RemoveObjectOptions) error { 48 | // panic("mock out the RemoveObject method") 49 | // }, 50 | // } 51 | // 52 | // // use mockedS3 in code that requires s3manager.S3 53 | // // and then make assertions. 54 | // 55 | // } 56 | type S3Mock struct { 57 | // GetObjectFunc mocks the GetObject method. 58 | GetObjectFunc func(ctx context.Context, bucketName string, objectName string, opts minio.GetObjectOptions) (*minio.Object, error) 59 | 60 | // ListBucketsFunc mocks the ListBuckets method. 61 | ListBucketsFunc func(ctx context.Context) ([]minio.BucketInfo, error) 62 | 63 | // ListObjectsFunc mocks the ListObjects method. 64 | ListObjectsFunc func(ctx context.Context, bucketName string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo 65 | 66 | // MakeBucketFunc mocks the MakeBucket method. 67 | MakeBucketFunc func(ctx context.Context, bucketName string, opts minio.MakeBucketOptions) error 68 | 69 | // PresignedGetObjectFunc mocks the PresignedGetObject method. 70 | PresignedGetObjectFunc func(ctx context.Context, bucketName string, objectName string, expiry time.Duration, reqParams url.Values) (*url.URL, error) 71 | 72 | // PutObjectFunc mocks the PutObject method. 73 | PutObjectFunc func(ctx context.Context, bucketName string, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (minio.UploadInfo, error) 74 | 75 | // RemoveBucketFunc mocks the RemoveBucket method. 76 | RemoveBucketFunc func(ctx context.Context, bucketName string) error 77 | 78 | // RemoveObjectFunc mocks the RemoveObject method. 79 | RemoveObjectFunc func(ctx context.Context, bucketName string, objectName string, opts minio.RemoveObjectOptions) error 80 | 81 | // calls tracks calls to the methods. 82 | calls struct { 83 | // GetObject holds details about calls to the GetObject method. 84 | GetObject []struct { 85 | // Ctx is the ctx argument value. 86 | Ctx context.Context 87 | // BucketName is the bucketName argument value. 88 | BucketName string 89 | // ObjectName is the objectName argument value. 90 | ObjectName string 91 | // Opts is the opts argument value. 92 | Opts minio.GetObjectOptions 93 | } 94 | // ListBuckets holds details about calls to the ListBuckets method. 95 | ListBuckets []struct { 96 | // Ctx is the ctx argument value. 97 | Ctx context.Context 98 | } 99 | // ListObjects holds details about calls to the ListObjects method. 100 | ListObjects []struct { 101 | // Ctx is the ctx argument value. 102 | Ctx context.Context 103 | // BucketName is the bucketName argument value. 104 | BucketName string 105 | // Opts is the opts argument value. 106 | Opts minio.ListObjectsOptions 107 | } 108 | // MakeBucket holds details about calls to the MakeBucket method. 109 | MakeBucket []struct { 110 | // Ctx is the ctx argument value. 111 | Ctx context.Context 112 | // BucketName is the bucketName argument value. 113 | BucketName string 114 | // Opts is the opts argument value. 115 | Opts minio.MakeBucketOptions 116 | } 117 | // PresignedGetObject holds details about calls to the PresignedGetObject method. 118 | PresignedGetObject []struct { 119 | // Ctx is the ctx argument value. 120 | Ctx context.Context 121 | // BucketName is the bucketName argument value. 122 | BucketName string 123 | // ObjectName is the objectName argument value. 124 | ObjectName string 125 | // Expiry is the expiry argument value. 126 | Expiry time.Duration 127 | // ReqParams is the reqParams argument value. 128 | ReqParams url.Values 129 | } 130 | // PutObject holds details about calls to the PutObject method. 131 | PutObject []struct { 132 | // Ctx is the ctx argument value. 133 | Ctx context.Context 134 | // BucketName is the bucketName argument value. 135 | BucketName string 136 | // ObjectName is the objectName argument value. 137 | ObjectName string 138 | // Reader is the reader argument value. 139 | Reader io.Reader 140 | // ObjectSize is the objectSize argument value. 141 | ObjectSize int64 142 | // Opts is the opts argument value. 143 | Opts minio.PutObjectOptions 144 | } 145 | // RemoveBucket holds details about calls to the RemoveBucket method. 146 | RemoveBucket []struct { 147 | // Ctx is the ctx argument value. 148 | Ctx context.Context 149 | // BucketName is the bucketName argument value. 150 | BucketName string 151 | } 152 | // RemoveObject holds details about calls to the RemoveObject method. 153 | RemoveObject []struct { 154 | // Ctx is the ctx argument value. 155 | Ctx context.Context 156 | // BucketName is the bucketName argument value. 157 | BucketName string 158 | // ObjectName is the objectName argument value. 159 | ObjectName string 160 | // Opts is the opts argument value. 161 | Opts minio.RemoveObjectOptions 162 | } 163 | } 164 | lockGetObject sync.RWMutex 165 | lockListBuckets sync.RWMutex 166 | lockListObjects sync.RWMutex 167 | lockMakeBucket sync.RWMutex 168 | lockPresignedGetObject sync.RWMutex 169 | lockPutObject sync.RWMutex 170 | lockRemoveBucket sync.RWMutex 171 | lockRemoveObject sync.RWMutex 172 | } 173 | 174 | // GetObject calls GetObjectFunc. 175 | func (mock *S3Mock) GetObject(ctx context.Context, bucketName string, objectName string, opts minio.GetObjectOptions) (*minio.Object, error) { 176 | if mock.GetObjectFunc == nil { 177 | panic("S3Mock.GetObjectFunc: method is nil but S3.GetObject was just called") 178 | } 179 | callInfo := struct { 180 | Ctx context.Context 181 | BucketName string 182 | ObjectName string 183 | Opts minio.GetObjectOptions 184 | }{ 185 | Ctx: ctx, 186 | BucketName: bucketName, 187 | ObjectName: objectName, 188 | Opts: opts, 189 | } 190 | mock.lockGetObject.Lock() 191 | mock.calls.GetObject = append(mock.calls.GetObject, callInfo) 192 | mock.lockGetObject.Unlock() 193 | return mock.GetObjectFunc(ctx, bucketName, objectName, opts) 194 | } 195 | 196 | // GetObjectCalls gets all the calls that were made to GetObject. 197 | // Check the length with: 198 | // 199 | // len(mockedS3.GetObjectCalls()) 200 | func (mock *S3Mock) GetObjectCalls() []struct { 201 | Ctx context.Context 202 | BucketName string 203 | ObjectName string 204 | Opts minio.GetObjectOptions 205 | } { 206 | var calls []struct { 207 | Ctx context.Context 208 | BucketName string 209 | ObjectName string 210 | Opts minio.GetObjectOptions 211 | } 212 | mock.lockGetObject.RLock() 213 | calls = mock.calls.GetObject 214 | mock.lockGetObject.RUnlock() 215 | return calls 216 | } 217 | 218 | // ListBuckets calls ListBucketsFunc. 219 | func (mock *S3Mock) ListBuckets(ctx context.Context) ([]minio.BucketInfo, error) { 220 | if mock.ListBucketsFunc == nil { 221 | panic("S3Mock.ListBucketsFunc: method is nil but S3.ListBuckets was just called") 222 | } 223 | callInfo := struct { 224 | Ctx context.Context 225 | }{ 226 | Ctx: ctx, 227 | } 228 | mock.lockListBuckets.Lock() 229 | mock.calls.ListBuckets = append(mock.calls.ListBuckets, callInfo) 230 | mock.lockListBuckets.Unlock() 231 | return mock.ListBucketsFunc(ctx) 232 | } 233 | 234 | // ListBucketsCalls gets all the calls that were made to ListBuckets. 235 | // Check the length with: 236 | // 237 | // len(mockedS3.ListBucketsCalls()) 238 | func (mock *S3Mock) ListBucketsCalls() []struct { 239 | Ctx context.Context 240 | } { 241 | var calls []struct { 242 | Ctx context.Context 243 | } 244 | mock.lockListBuckets.RLock() 245 | calls = mock.calls.ListBuckets 246 | mock.lockListBuckets.RUnlock() 247 | return calls 248 | } 249 | 250 | // ListObjects calls ListObjectsFunc. 251 | func (mock *S3Mock) ListObjects(ctx context.Context, bucketName string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo { 252 | if mock.ListObjectsFunc == nil { 253 | panic("S3Mock.ListObjectsFunc: method is nil but S3.ListObjects was just called") 254 | } 255 | callInfo := struct { 256 | Ctx context.Context 257 | BucketName string 258 | Opts minio.ListObjectsOptions 259 | }{ 260 | Ctx: ctx, 261 | BucketName: bucketName, 262 | Opts: opts, 263 | } 264 | mock.lockListObjects.Lock() 265 | mock.calls.ListObjects = append(mock.calls.ListObjects, callInfo) 266 | mock.lockListObjects.Unlock() 267 | return mock.ListObjectsFunc(ctx, bucketName, opts) 268 | } 269 | 270 | // ListObjectsCalls gets all the calls that were made to ListObjects. 271 | // Check the length with: 272 | // 273 | // len(mockedS3.ListObjectsCalls()) 274 | func (mock *S3Mock) ListObjectsCalls() []struct { 275 | Ctx context.Context 276 | BucketName string 277 | Opts minio.ListObjectsOptions 278 | } { 279 | var calls []struct { 280 | Ctx context.Context 281 | BucketName string 282 | Opts minio.ListObjectsOptions 283 | } 284 | mock.lockListObjects.RLock() 285 | calls = mock.calls.ListObjects 286 | mock.lockListObjects.RUnlock() 287 | return calls 288 | } 289 | 290 | // MakeBucket calls MakeBucketFunc. 291 | func (mock *S3Mock) MakeBucket(ctx context.Context, bucketName string, opts minio.MakeBucketOptions) error { 292 | if mock.MakeBucketFunc == nil { 293 | panic("S3Mock.MakeBucketFunc: method is nil but S3.MakeBucket was just called") 294 | } 295 | callInfo := struct { 296 | Ctx context.Context 297 | BucketName string 298 | Opts minio.MakeBucketOptions 299 | }{ 300 | Ctx: ctx, 301 | BucketName: bucketName, 302 | Opts: opts, 303 | } 304 | mock.lockMakeBucket.Lock() 305 | mock.calls.MakeBucket = append(mock.calls.MakeBucket, callInfo) 306 | mock.lockMakeBucket.Unlock() 307 | return mock.MakeBucketFunc(ctx, bucketName, opts) 308 | } 309 | 310 | // MakeBucketCalls gets all the calls that were made to MakeBucket. 311 | // Check the length with: 312 | // 313 | // len(mockedS3.MakeBucketCalls()) 314 | func (mock *S3Mock) MakeBucketCalls() []struct { 315 | Ctx context.Context 316 | BucketName string 317 | Opts minio.MakeBucketOptions 318 | } { 319 | var calls []struct { 320 | Ctx context.Context 321 | BucketName string 322 | Opts minio.MakeBucketOptions 323 | } 324 | mock.lockMakeBucket.RLock() 325 | calls = mock.calls.MakeBucket 326 | mock.lockMakeBucket.RUnlock() 327 | return calls 328 | } 329 | 330 | // PresignedGetObject calls PresignedGetObjectFunc. 331 | func (mock *S3Mock) PresignedGetObject(ctx context.Context, bucketName string, objectName string, expiry time.Duration, reqParams url.Values) (*url.URL, error) { 332 | if mock.PresignedGetObjectFunc == nil { 333 | panic("S3Mock.PresignedGetObjectFunc: method is nil but S3.PresignedGetObject was just called") 334 | } 335 | callInfo := struct { 336 | Ctx context.Context 337 | BucketName string 338 | ObjectName string 339 | Expiry time.Duration 340 | ReqParams url.Values 341 | }{ 342 | Ctx: ctx, 343 | BucketName: bucketName, 344 | ObjectName: objectName, 345 | Expiry: expiry, 346 | ReqParams: reqParams, 347 | } 348 | mock.lockPresignedGetObject.Lock() 349 | mock.calls.PresignedGetObject = append(mock.calls.PresignedGetObject, callInfo) 350 | mock.lockPresignedGetObject.Unlock() 351 | return mock.PresignedGetObjectFunc(ctx, bucketName, objectName, expiry, reqParams) 352 | } 353 | 354 | // PresignedGetObjectCalls gets all the calls that were made to PresignedGetObject. 355 | // Check the length with: 356 | // 357 | // len(mockedS3.PresignedGetObjectCalls()) 358 | func (mock *S3Mock) PresignedGetObjectCalls() []struct { 359 | Ctx context.Context 360 | BucketName string 361 | ObjectName string 362 | Expiry time.Duration 363 | ReqParams url.Values 364 | } { 365 | var calls []struct { 366 | Ctx context.Context 367 | BucketName string 368 | ObjectName string 369 | Expiry time.Duration 370 | ReqParams url.Values 371 | } 372 | mock.lockPresignedGetObject.RLock() 373 | calls = mock.calls.PresignedGetObject 374 | mock.lockPresignedGetObject.RUnlock() 375 | return calls 376 | } 377 | 378 | // PutObject calls PutObjectFunc. 379 | func (mock *S3Mock) PutObject(ctx context.Context, bucketName string, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (minio.UploadInfo, error) { 380 | if mock.PutObjectFunc == nil { 381 | panic("S3Mock.PutObjectFunc: method is nil but S3.PutObject was just called") 382 | } 383 | callInfo := struct { 384 | Ctx context.Context 385 | BucketName string 386 | ObjectName string 387 | Reader io.Reader 388 | ObjectSize int64 389 | Opts minio.PutObjectOptions 390 | }{ 391 | Ctx: ctx, 392 | BucketName: bucketName, 393 | ObjectName: objectName, 394 | Reader: reader, 395 | ObjectSize: objectSize, 396 | Opts: opts, 397 | } 398 | mock.lockPutObject.Lock() 399 | mock.calls.PutObject = append(mock.calls.PutObject, callInfo) 400 | mock.lockPutObject.Unlock() 401 | return mock.PutObjectFunc(ctx, bucketName, objectName, reader, objectSize, opts) 402 | } 403 | 404 | // PutObjectCalls gets all the calls that were made to PutObject. 405 | // Check the length with: 406 | // 407 | // len(mockedS3.PutObjectCalls()) 408 | func (mock *S3Mock) PutObjectCalls() []struct { 409 | Ctx context.Context 410 | BucketName string 411 | ObjectName string 412 | Reader io.Reader 413 | ObjectSize int64 414 | Opts minio.PutObjectOptions 415 | } { 416 | var calls []struct { 417 | Ctx context.Context 418 | BucketName string 419 | ObjectName string 420 | Reader io.Reader 421 | ObjectSize int64 422 | Opts minio.PutObjectOptions 423 | } 424 | mock.lockPutObject.RLock() 425 | calls = mock.calls.PutObject 426 | mock.lockPutObject.RUnlock() 427 | return calls 428 | } 429 | 430 | // RemoveBucket calls RemoveBucketFunc. 431 | func (mock *S3Mock) RemoveBucket(ctx context.Context, bucketName string) error { 432 | if mock.RemoveBucketFunc == nil { 433 | panic("S3Mock.RemoveBucketFunc: method is nil but S3.RemoveBucket was just called") 434 | } 435 | callInfo := struct { 436 | Ctx context.Context 437 | BucketName string 438 | }{ 439 | Ctx: ctx, 440 | BucketName: bucketName, 441 | } 442 | mock.lockRemoveBucket.Lock() 443 | mock.calls.RemoveBucket = append(mock.calls.RemoveBucket, callInfo) 444 | mock.lockRemoveBucket.Unlock() 445 | return mock.RemoveBucketFunc(ctx, bucketName) 446 | } 447 | 448 | // RemoveBucketCalls gets all the calls that were made to RemoveBucket. 449 | // Check the length with: 450 | // 451 | // len(mockedS3.RemoveBucketCalls()) 452 | func (mock *S3Mock) RemoveBucketCalls() []struct { 453 | Ctx context.Context 454 | BucketName string 455 | } { 456 | var calls []struct { 457 | Ctx context.Context 458 | BucketName string 459 | } 460 | mock.lockRemoveBucket.RLock() 461 | calls = mock.calls.RemoveBucket 462 | mock.lockRemoveBucket.RUnlock() 463 | return calls 464 | } 465 | 466 | // RemoveObject calls RemoveObjectFunc. 467 | func (mock *S3Mock) RemoveObject(ctx context.Context, bucketName string, objectName string, opts minio.RemoveObjectOptions) error { 468 | if mock.RemoveObjectFunc == nil { 469 | panic("S3Mock.RemoveObjectFunc: method is nil but S3.RemoveObject was just called") 470 | } 471 | callInfo := struct { 472 | Ctx context.Context 473 | BucketName string 474 | ObjectName string 475 | Opts minio.RemoveObjectOptions 476 | }{ 477 | Ctx: ctx, 478 | BucketName: bucketName, 479 | ObjectName: objectName, 480 | Opts: opts, 481 | } 482 | mock.lockRemoveObject.Lock() 483 | mock.calls.RemoveObject = append(mock.calls.RemoveObject, callInfo) 484 | mock.lockRemoveObject.Unlock() 485 | return mock.RemoveObjectFunc(ctx, bucketName, objectName, opts) 486 | } 487 | 488 | // RemoveObjectCalls gets all the calls that were made to RemoveObject. 489 | // Check the length with: 490 | // 491 | // len(mockedS3.RemoveObjectCalls()) 492 | func (mock *S3Mock) RemoveObjectCalls() []struct { 493 | Ctx context.Context 494 | BucketName string 495 | ObjectName string 496 | Opts minio.RemoveObjectOptions 497 | } { 498 | var calls []struct { 499 | Ctx context.Context 500 | BucketName string 501 | ObjectName string 502 | Opts minio.RemoveObjectOptions 503 | } 504 | mock.lockRemoveObject.RLock() 505 | calls = mock.calls.RemoveObject 506 | mock.lockRemoveObject.RUnlock() 507 | return calls 508 | } 509 | -------------------------------------------------------------------------------- /web/static/js/jquery-3.6.0.min.js: -------------------------------------------------------------------------------- 1 | /*! jQuery v3.6.0 | (c) OpenJS Foundation and other contributors | jquery.org/license */ 2 | !function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.0",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var _t,zt=[],Ut=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=zt.pop()||S.expando+"_"+wt.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Ut.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Ut.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Ut,"$1"+r):!1!==e.jsonp&&(e.url+=(Tt.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,zt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((_t=E.implementation.createHTMLDocument("").body).innerHTML="
",2===_t.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=Fe(y.pixelPosition,function(e,t){if(t)return t=We(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0