├── .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 |
3 |
4 |
S3 Manager
5 | {{ if .CurrentS3 }}
6 |
7 | storage {{ .CurrentS3.Name }}
8 |
9 | {{ end }}
10 |
11 |
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 |
69 | add
70 |
71 |
72 | {{ end }}
73 |
74 |
93 |
94 |
111 | {{ end }}
112 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # S3 Manager
2 |
3 | [](https://goreportcard.com/report/github.com/cloudlena/s3manager)
4 | [](https://github.com/cloudlena/s3manager/actions)
5 |
6 | A Web GUI written in Go to manage S3 buckets from any provider.
7 |
8 | 
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 | [](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 |
75 | storage
76 |
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 |
15 |
16 |
folder_open {{ .BucketName }}
17 | {{ if and (not .Objects) (not .HasError) }}
18 |
25 | {{ end }}
26 |
27 | {{ if .CurrentS3 }}
28 | storage {{ .CurrentS3.Name }}
29 | {{ end }}
30 |
31 |
32 |
33 |
34 |
arrow_back buckets
35 | {{ $url := printf "%s/buckets/%s/" $.RootURL $.BucketName }}
36 |
{{ $.BucketName }}
37 |
38 | {{ range $index, $path := .Paths }}
39 | {{ $url = printf "%s%s/" $url $path }}
40 |
{{ $path }}
41 | {{ end -}}
42 |
43 |
44 |
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 | Key
78 | Size
79 | Owner
80 | Last Modified
81 |
82 |
83 |
84 |
85 |
86 | {{ range $index, $object := .Objects }}
87 |
88 |
93 | {{ $object.Icon }} {{ $object.DisplayName }}
94 |
95 | {{ $object.Size }} bytes
96 | {{ $object.Owner }}
97 | {{ $object.LastModified }}
98 |
99 | {{ if not $object.IsFolder }}
100 |
101 | Actions arrow_drop_down
102 |
103 |
104 |
105 | Download
106 | Download link
107 | {{- if $.AllowDelete }}
108 | Delete
109 | {{- end }}
110 |
111 | {{ end }}
112 |
113 |
114 | {{ end }}
115 |
116 |
117 | {{ else }}
118 |
No objects in {{ .BucketName }}/{{ .CurrentPath }} yet
119 | {{ end }}
120 |
121 |
122 |
123 |
124 |
Uploading
125 |
I am a very simple card
126 |
129 |
130 |
131 |
132 |
133 |
134 | {{ if not .HasError }}
135 |
136 |
137 | add
138 |
139 |
140 |
141 | create_new_folder
142 |
143 |
144 |
145 | create
146 |
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