├── .golangci.yml ├── .vscode └── settings.json ├── go.mod ├── config.json ├── Dockerfile ├── docker-compose.e2e.yml ├── .github └── workflows │ ├── go.yml │ ├── docker.yml │ └── golangci-lint.yml ├── .gitignore ├── pkg ├── file_naming.go ├── server_test.go ├── server.go └── server_it_test.go ├── go.sum ├── LICENSE ├── app_test.go ├── app.go └── README.md /.golangci.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "markdown.extension.toc.levels": "2..3" 3 | } 4 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mayth/go-simple-upload-server/v2 2 | 3 | go 1.21 4 | 5 | require ( 6 | dario.cat/mergo v1.0.0 7 | github.com/google/uuid v1.6.0 8 | github.com/gorilla/mux v1.8.1 9 | github.com/spf13/afero v1.11.0 10 | ) 11 | 12 | require golang.org/x/text v0.14.0 // indirect 13 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "addr": ":8088", 3 | "document_root": "/opt/app", 4 | "enable_cors": true, 5 | "max_upload_size": 1048576, 6 | "file_naming_strategy": "uuid", 7 | "shutdown_timeout": 15000, 8 | "enable_auth": true, 9 | "read_only_tokens": [], 10 | "read_write_tokens": [], 11 | "read_timeout": "30s", 12 | "write_timeout": 30 13 | } 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ARCH=amd64 2 | FROM golang:1.21 AS build 3 | 4 | LABEL org.opencontainers.image.authors="Mei Akizuru " 5 | 6 | RUN mkdir -p /go/src/app 7 | WORKDIR /go/src/app 8 | 9 | # resolve dependency before copying whole source code 10 | COPY go.mod go.sum ./ 11 | RUN go mod download 12 | 13 | # copy other sources & build 14 | COPY . /go/src/app 15 | RUN GOOS=linux GOARCH=${ARCH} CGO_ENABLED=0 go build -o /go/bin/app 16 | 17 | FROM scratch 18 | COPY --from=build /go/bin/app /usr/local/bin/app 19 | ENTRYPOINT ["/usr/local/bin/app"] 20 | -------------------------------------------------------------------------------- /docker-compose.e2e.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: 4 | context: . 5 | tags: 6 | - "mayth/simple-upload-server:testing" 7 | ports: 8 | - "127.0.0.1::8080" 9 | volumes: 10 | - docroot:/docroot 11 | command: -document_root=/docroot -addr=:8080 12 | test: 13 | image: golang:1.21 14 | environment: 15 | - TEST_WITH_REAL_FS=/docroot 16 | - TEST_TARGET_ADDR=app:8080 17 | volumes: 18 | - .:/app 19 | - docroot:/docroot 20 | working_dir: /app 21 | command: go test -v -run TestServer ./... 22 | depends_on: 23 | - app 24 | volumes: 25 | docroot: 26 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "v2" ] 9 | pull_request: 10 | branches: [ "v2" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.21' 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/go 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=go 3 | 4 | ### Go ### 5 | # If you prefer the allow list template instead of the deny list, see community template: 6 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 7 | # 8 | # Binaries for programs and plugins 9 | *.exe 10 | *.exe~ 11 | *.dll 12 | *.so 13 | *.dylib 14 | 15 | # Test binary, built with `go test -c` 16 | *.test 17 | 18 | # Output of the go coverage tool, specifically when used with LiteIDE 19 | *.out 20 | 21 | # Dependency directories (remove the comment below to include it) 22 | # vendor/ 23 | 24 | # Go workspace file 25 | go.work 26 | 27 | # End of https://www.toptal.com/developers/gitignore/api/go 28 | 29 | # A files for testing TLS in local environment 30 | .tls 31 | -------------------------------------------------------------------------------- /pkg/file_naming.go: -------------------------------------------------------------------------------- 1 | package simpleuploadserver 2 | 3 | import ( 4 | "crypto/sha256" 5 | "fmt" 6 | "io" 7 | "mime/multipart" 8 | "strings" 9 | 10 | "github.com/google/uuid" 11 | ) 12 | 13 | type FileNamingStrategy func(multipart.File, *multipart.FileHeader) (string, error) 14 | 15 | func UUIDStrategy(multipart.File, *multipart.FileHeader) (string, error) { 16 | return uuid.NewString(), nil 17 | } 18 | 19 | func SHA256Strategy(file multipart.File, info *multipart.FileHeader) (string, error) { 20 | h := sha256.New() 21 | if _, err := io.Copy(h, file); err != nil { 22 | return "", err 23 | } 24 | return fmt.Sprintf("%x", h.Sum(nil)), nil 25 | } 26 | 27 | var strategies = map[string]FileNamingStrategy{ 28 | "uuid": UUIDStrategy, 29 | "sha256": SHA256Strategy, 30 | } 31 | 32 | var DefaultNamingStrategy FileNamingStrategy = UUIDStrategy 33 | 34 | func ResolveFileNamingStrategy(name string) FileNamingStrategy { 35 | if name == "" { 36 | return DefaultNamingStrategy 37 | } 38 | return strategies[strings.ToLower(name)] 39 | } 40 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 4 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 5 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 6 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 7 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 8 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 9 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 10 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 12 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 13 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Mei Akizuru 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: [ "v2" ] 6 | pull_request: 7 | branches: [ "v2" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Build the Docker image 15 | run: docker build . --file Dockerfile --tag mayth/simple-upload-server:$(date +%s) 16 | 17 | push: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Set up QEMU 22 | uses: docker/setup-qemu-action@v3 23 | - name: Setup Docker Buildx 24 | uses: docker/setup-buildx-action@v3 25 | - name: Log in to Docker Hub 26 | uses: docker/login-action@v3 27 | with: 28 | username: ${{ secrets.DOCKER_USERNAME }} 29 | password: ${{ secrets.DOCKER_PASSWORD }} 30 | - name: Test 31 | run: | 32 | docker compose -f docker-compose.e2e.yml run --rm test 33 | docker compose -f docker-compose.e2e.yml down --rmi local --volumes 34 | - name: Build and Publish 35 | uses: docker/build-push-action@v5 36 | with: 37 | platforms: linux/amd64,linux/arm64 38 | push: true 39 | tags: mayth/simple-upload-server:latest 40 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | branches: 5 | - v2 6 | pull_request: 7 | 8 | permissions: 9 | contents: read 10 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 11 | # pull-requests: read 12 | 13 | jobs: 14 | golangci: 15 | name: lint 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-go@v4 20 | with: 21 | go-version: '1.21' 22 | cache: false 23 | - name: golangci-lint 24 | uses: golangci/golangci-lint-action@v3 25 | with: 26 | # Require: The version of golangci-lint to use. 27 | # When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version. 28 | # When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit. 29 | version: v1.55 30 | 31 | # Optional: working directory, useful for monorepos 32 | # working-directory: somedir 33 | 34 | # Optional: golangci-lint command line arguments. 35 | # 36 | # Note: By default, the `.golangci.yml` file should be at the root of the repository. 37 | # The location of the configuration file can be changed by using `--config=` 38 | # args: --timeout=30m --config=/my/path/.golangci.yml --issues-exit-code=0 39 | 40 | # Optional: show only new issues if it's a pull request. The default value is `false`. 41 | # only-new-issues: true 42 | 43 | # Optional: if set to true, then all caching functionality will be completely disabled, 44 | # takes precedence over all other caching options. 45 | # skip-cache: true 46 | 47 | # Optional: if set to true, then the action won't cache or restore ~/go/pkg. 48 | # skip-pkg-cache: true 49 | 50 | # Optional: if set to true, then the action won't cache or restore ~/.cache/go-build. 51 | # skip-build-cache: true 52 | 53 | # Optional: The mode to install golangci-lint. It can be 'binary' or 'goinstall'. 54 | # install-mode: "goinstall" 55 | -------------------------------------------------------------------------------- /app_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "testing" 7 | "time" 8 | 9 | simpleuploadserver "github.com/mayth/go-simple-upload-server/v2/pkg" 10 | ) 11 | 12 | func Test_parseConfig(t *testing.T) { 13 | t.Run("use config file only", func(t *testing.T) { 14 | f, err := os.CreateTemp("", "simple-upload-server-config.*.json") 15 | if err != nil { 16 | t.Fatalf("failed to create temp file: %v", err) 17 | } 18 | defer os.Remove(f.Name()) 19 | defer f.Close() 20 | if _, err := f.WriteString(`{ 21 | "addr": ":8123", 22 | "document_root": "/opt/app", 23 | "enable_cors": true, 24 | "max_upload_size": 1234567, 25 | "file_naming_strategy": "uuid", 26 | "shutdown_timeout": 15000, 27 | "enable_auth": true, 28 | "read_only_tokens": ["foo", "bar"], 29 | "read_write_tokens": ["baz", "qux"], 30 | "read_timeout": "5s", 31 | "write_timeout": "10s" 32 | }`); err != nil { 33 | t.Fatalf("failed to write to temp file: %v", err) 34 | } 35 | if err := f.Sync(); err != nil { 36 | t.Fatalf("failed to sync temp file: %v", err) 37 | } 38 | if _, err := f.Seek(0, 0); err != nil { 39 | t.Fatalf("failed to seek temp file: %v", err) 40 | } 41 | 42 | app := NewApp(os.Args[0]) 43 | got, err := app.ParseConfig([]string{"-config", f.Name()}) 44 | if err != nil { 45 | t.Errorf("parseConfig() error = %v", err) 46 | return 47 | } 48 | want := &simpleuploadserver.ServerConfig{ 49 | Addr: ":8123", 50 | DocumentRoot: "/opt/app", 51 | EnableCORS: true, 52 | MaxUploadSize: 1234567, 53 | FileNamingStrategy: "uuid", 54 | ShutdownTimeout: 15000, 55 | EnableAuth: true, 56 | ReadOnlyTokens: []string{"foo", "bar"}, 57 | ReadWriteTokens: []string{"baz", "qux"}, 58 | ReadTimeout: 5 * time.Second, 59 | WriteTimeout: 10 * time.Second, 60 | } 61 | if !reflect.DeepEqual(got, want) { 62 | t.Errorf("parseConfig() = %v, want %v", got, want) 63 | } 64 | }) 65 | 66 | t.Run("use flag only", func(t *testing.T) { 67 | app := NewApp(os.Args[0]) 68 | args := []string{ 69 | "-addr", ":8987", 70 | "-document_root", "/tmp/sus", 71 | "-enable_cors=false", 72 | "-max_upload_size", "987654", 73 | "-file_naming_strategy", "uuid", 74 | "-shutdown_timeout", "30000", 75 | "-enable_auth=true", 76 | "-read_only_tokens", "foo,bar", 77 | "-read_write_tokens", "baz,qux", 78 | "-read_timeout", "7s", 79 | "-write_timeout", "12s", 80 | } 81 | got, err := app.ParseConfig(args) 82 | if err != nil { 83 | t.Errorf("parseConfig() error = %v", err) 84 | return 85 | } 86 | want := &simpleuploadserver.ServerConfig{ 87 | Addr: ":8987", 88 | DocumentRoot: "/tmp/sus", 89 | EnableCORS: false, 90 | MaxUploadSize: 987654, 91 | FileNamingStrategy: "uuid", 92 | ShutdownTimeout: 30000, 93 | EnableAuth: true, 94 | ReadOnlyTokens: []string{"foo", "bar"}, 95 | ReadWriteTokens: []string{"baz", "qux"}, 96 | ReadTimeout: 7 * time.Second, 97 | WriteTimeout: 12 * time.Second, 98 | } 99 | if !reflect.DeepEqual(got, want) { 100 | t.Errorf("parseConfig() = %v, want %v", got, want) 101 | } 102 | }) 103 | 104 | t.Run("flag options precedes config file", func(t *testing.T) { 105 | app := NewApp(os.Args[0]) 106 | args := []string{ 107 | "-addr", ":8987", 108 | "-document_root", "/tmp/sus", 109 | "-enable_cors=true", 110 | "-max_upload_size", "987654", 111 | "-file_naming_strategy", "uuid", 112 | "-shutdown_timeout", "30000", 113 | "-read_timeout", "16s", 114 | } 115 | 116 | f, err := os.CreateTemp("", "simple-upload-server-config.*.json") 117 | if err != nil { 118 | t.Fatalf("failed to create temp file: %v", err) 119 | } 120 | defer os.Remove(f.Name()) 121 | defer f.Close() 122 | if _, err := f.WriteString(`{ 123 | "addr": ":8123", 124 | "document_root": "/opt/app", 125 | "enable_cors": true, 126 | "max_upload_size": 1234567, 127 | "file_naming_strategy": "uuid", 128 | "shutdown_timeout": 15000, 129 | "enable_auth": true, 130 | "read_only_tokens": ["alice", "bob"], 131 | "read_write_tokens": ["charlie", "dave"], 132 | "read_timeout": "32s", 133 | "write_timeout": "64s" 134 | }`); err != nil { 135 | t.Fatalf("failed to write to temp file: %v", err) 136 | } 137 | if err := f.Sync(); err != nil { 138 | t.Fatalf("failed to sync temp file: %v", err) 139 | } 140 | if _, err := f.Seek(0, 0); err != nil { 141 | t.Fatalf("failed to seek temp file: %v", err) 142 | } 143 | args = append(args, "-config", f.Name()) 144 | 145 | got, err := app.ParseConfig(args) 146 | if err != nil { 147 | t.Errorf("parseConfig() error = %v", err) 148 | return 149 | } 150 | want := &simpleuploadserver.ServerConfig{ 151 | Addr: ":8987", 152 | DocumentRoot: "/tmp/sus", 153 | EnableCORS: true, 154 | MaxUploadSize: 987654, 155 | FileNamingStrategy: "uuid", 156 | ShutdownTimeout: 30000, 157 | EnableAuth: true, 158 | ReadOnlyTokens: []string{"alice", "bob"}, 159 | ReadWriteTokens: []string{"charlie", "dave"}, 160 | ReadTimeout: 16 * time.Second, 161 | WriteTimeout: 64 * time.Second, 162 | } 163 | if !reflect.DeepEqual(got, want) { 164 | t.Errorf("parseConfig() = %v, want %v", got, want) 165 | } 166 | }) 167 | } 168 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "crypto/sha256" 7 | "encoding/json" 8 | "flag" 9 | "fmt" 10 | "log" 11 | "os" 12 | "os/signal" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | "dario.cat/mergo" 18 | simpleuploadserver "github.com/mayth/go-simple-upload-server/v2/pkg" 19 | ) 20 | 21 | var DefaultConfig = ServerConfig{ 22 | DocumentRoot: ".", 23 | Addr: simpleuploadserver.DefaultAddr, 24 | EnableCORS: nil, 25 | MaxUploadSize: 1024 * 1024, 26 | FileNamingStrategy: "uuid", 27 | ShutdownTimeout: 15000, 28 | EnableAuth: nil, 29 | ReadOnlyTokens: []string{}, 30 | ReadWriteTokens: []string{}, 31 | ReadTimeout: Duration(15 * time.Second), 32 | WriteTimeout: 0, 33 | } 34 | 35 | func BoolPointer(v bool) *bool { 36 | return &v 37 | } 38 | 39 | type triBool struct { 40 | value bool 41 | isSet bool 42 | } 43 | 44 | type boolOptFlag triBool 45 | 46 | func (f *boolOptFlag) Set(value string) error { 47 | v, err := strconv.ParseBool(value) 48 | if err != nil { 49 | return err 50 | } 51 | f.value = v 52 | f.isSet = true 53 | return nil 54 | } 55 | 56 | func (f boolOptFlag) String() string { 57 | return strconv.FormatBool(f.value) 58 | } 59 | 60 | func (f boolOptFlag) IsBoolFlag() bool { 61 | return true 62 | } 63 | 64 | func (f boolOptFlag) IsSet() bool { 65 | return f.isSet 66 | } 67 | 68 | func (f boolOptFlag) Get() any { 69 | return f.value 70 | } 71 | 72 | type stringArrayFlag []string 73 | 74 | func (f *stringArrayFlag) Set(value string) error { 75 | ss := strings.Split(value, ",") 76 | *f = ss 77 | return nil 78 | } 79 | 80 | func (f stringArrayFlag) String() string { 81 | return strings.Join(f, ",") 82 | } 83 | 84 | type Duration time.Duration 85 | 86 | func (d Duration) MarshalJSON() ([]byte, error) { 87 | return json.Marshal(time.Duration(d).Seconds()) 88 | } 89 | 90 | func (d *Duration) UnmarshalJSON(data []byte) error { 91 | var v interface{} 92 | if err := json.Unmarshal(data, &v); err != nil { 93 | return err 94 | } 95 | switch t := v.(type) { 96 | case float64: 97 | *d = Duration(time.Duration(t) * time.Second) 98 | case string: 99 | dur, err := time.ParseDuration(t) 100 | if err != nil { 101 | return err 102 | } 103 | *d = Duration(dur) 104 | default: 105 | return fmt.Errorf("invalid duration type: %T", t) 106 | } 107 | return nil 108 | } 109 | 110 | // ServerConfig wraps simpleuploadserver.ServerConfig to provide JSON marshaling. 111 | type ServerConfig struct { 112 | // Address where the server listens on. 113 | Addr string `json:"addr"` 114 | // Path to the document root. 115 | DocumentRoot string `json:"document_root"` 116 | // Determines whether to enable CORS header. 117 | EnableCORS *bool `json:"enable_cors"` 118 | // Maximum upload size in bytes. 119 | MaxUploadSize int64 `json:"max_upload_size"` 120 | // File naming strategy. 121 | FileNamingStrategy string `json:"file_naming_strategy"` 122 | // Graceful shutdown timeout in milliseconds. 123 | ShutdownTimeout int `json:"shutdown_timeout"` 124 | // Enable authentication. 125 | EnableAuth *bool `json:"enable_auth"` 126 | // Authentication tokens for read-only access. 127 | ReadOnlyTokens []string `json:"read_only_tokens"` 128 | // Authentication tokens for read-write access. 129 | ReadWriteTokens []string `json:"read_write_tokens"` 130 | // ReadTimeout is the maximum duration for reading the entire request, including the body. Zero or negative value means no timeout. 131 | ReadTimeout Duration `json:"read_timeout"` 132 | // WriteTimeout is the maximum duration for writing the response. Zero or negative value means no timeout. 133 | WriteTimeout Duration `json:"write_timeout"` 134 | } 135 | 136 | func (c *ServerConfig) AsConfig() simpleuploadserver.ServerConfig { 137 | if c.EnableCORS == nil { 138 | c.EnableCORS = BoolPointer(true) 139 | } 140 | if c.EnableAuth == nil { 141 | c.EnableAuth = BoolPointer(false) 142 | } 143 | 144 | return simpleuploadserver.ServerConfig{ 145 | Addr: c.Addr, 146 | DocumentRoot: c.DocumentRoot, 147 | EnableCORS: *c.EnableCORS, 148 | MaxUploadSize: c.MaxUploadSize, 149 | FileNamingStrategy: c.FileNamingStrategy, 150 | ShutdownTimeout: c.ShutdownTimeout, 151 | EnableAuth: *c.EnableAuth, 152 | ReadOnlyTokens: c.ReadOnlyTokens, 153 | ReadWriteTokens: c.ReadWriteTokens, 154 | ReadTimeout: time.Duration(c.ReadTimeout), 155 | WriteTimeout: time.Duration(c.WriteTimeout), 156 | } 157 | } 158 | 159 | func main() { 160 | NewApp(os.Args[0]).Run(os.Args[1:]) 161 | } 162 | 163 | type app struct { 164 | flagSet *flag.FlagSet 165 | configFilePath string 166 | documentRoot string 167 | addr string 168 | enableCORS boolOptFlag 169 | maxUploadSize int64 170 | fileNamingStrategy string 171 | shutdownTimeout int 172 | enableAuth boolOptFlag 173 | readOnlyTokens stringArrayFlag 174 | readWriteTokens stringArrayFlag 175 | readTimeout time.Duration 176 | writeTimeout time.Duration 177 | } 178 | 179 | func NewApp(name string) *app { 180 | a := &app{} 181 | fs := flag.NewFlagSet(name, flag.ExitOnError) 182 | fs.StringVar(&a.configFilePath, "config", "", "path to config file") 183 | fs.StringVar(&a.documentRoot, "document_root", "", "path to document root directory") 184 | fs.StringVar(&a.addr, "addr", "", "address to listen") 185 | fs.Var(&a.enableCORS, "enable_cors", "enable CORS header") 186 | fs.Int64Var(&a.maxUploadSize, "max_upload_size", 0, "max upload size in bytes") 187 | fs.StringVar(&a.fileNamingStrategy, "file_naming_strategy", "", "File naming strategy") 188 | fs.IntVar(&a.shutdownTimeout, "shutdown_timeout", 0, "graceful shutdown timeout in milliseconds") 189 | fs.Var(&a.enableAuth, "enable_auth", "enable authentication") 190 | fs.Var(&a.readOnlyTokens, "read_only_tokens", "comma separated list of read only tokens") 191 | fs.Var(&a.readWriteTokens, "read_write_tokens", "comma separated list of read write tokens") 192 | fs.DurationVar(&a.readTimeout, "read_timeout", 0, "read timeout. zero or negative value means no timeout. can be suffixed by the time units 'ns', 'us' (or 'µs'), 'ms', 's', 'm', 'h' (e.g. '1s', '500ms'). If no suffix is provided, it is interpreted as seconds.") 193 | fs.DurationVar(&a.writeTimeout, "write_timeout", 0, "write timeout. zero or negative value means no timeout. same format as read_timeout.") 194 | a.flagSet = fs 195 | return a 196 | } 197 | 198 | func (a *app) Run(args []string) { 199 | config, err := a.ParseConfig(args) 200 | if err != nil { 201 | log.Fatalf("failed to parse config: %v", err) 202 | } 203 | log.Printf("configured: %+v", config) 204 | 205 | if config.EnableAuth && len(config.ReadOnlyTokens) == 0 && len(config.ReadWriteTokens) == 0 { 206 | log.Print("[NOTICE] Authentication is enabled but no tokens provided. generating random tokens") 207 | readOnlyToken, err := generateToken() 208 | if err != nil { 209 | log.Fatalf("failed to generate read only token: %v", err) 210 | } 211 | readWriteToken, err := generateToken() 212 | if err != nil { 213 | log.Fatalf("failed to generate read write token: %v", err) 214 | } 215 | config.ReadOnlyTokens = append(config.ReadOnlyTokens, readOnlyToken) 216 | config.ReadWriteTokens = append(config.ReadWriteTokens, readWriteToken) 217 | log.Printf("generated read only token: %s", readOnlyToken) 218 | log.Printf("generated read write token: %s", readWriteToken) 219 | } 220 | 221 | s := simpleuploadserver.NewServer(*config) 222 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 223 | defer stop() 224 | err = s.Start(ctx, nil) 225 | log.Printf("server stopped: %v", err) 226 | } 227 | 228 | func generateToken() (string, error) { 229 | randBytes := make([]byte, 32) 230 | if _, err := rand.Read(randBytes); err != nil { 231 | return "", err 232 | } 233 | b := sha256.Sum256(randBytes) 234 | return fmt.Sprintf("%x", b), nil 235 | } 236 | 237 | // parseConfig parses the configuration from the `src` and merges it with the `orig` configuration. 238 | func (a *app) ParseConfig(args []string) (*simpleuploadserver.ServerConfig, error) { 239 | if err := a.flagSet.Parse(args); err != nil { 240 | return nil, err 241 | } 242 | 243 | config := DefaultConfig 244 | 245 | if a.configFilePath != "" { 246 | f, err := os.Open(a.configFilePath) 247 | if err != nil { 248 | log.Fatalf("failed to open config file: %v", err) 249 | } 250 | defer f.Close() 251 | fileConfig := ServerConfig{} 252 | if err := json.NewDecoder(f).Decode(&fileConfig); err != nil { 253 | return nil, fmt.Errorf("failed to load config: %w", err) 254 | } 255 | log.Printf("loaded config from source file: %+v", fileConfig) 256 | if err := mergo.Merge(&config, fileConfig, mergo.WithOverride); err != nil { 257 | return nil, fmt.Errorf("failed to merge config from file: %w", err) 258 | } 259 | log.Printf("merged file config: %+v", config) 260 | } else { 261 | log.Print("no config file provided") 262 | } 263 | 264 | configFromFlags := ServerConfig{ 265 | DocumentRoot: a.documentRoot, 266 | Addr: a.addr, 267 | MaxUploadSize: a.maxUploadSize, 268 | FileNamingStrategy: a.fileNamingStrategy, 269 | ShutdownTimeout: a.shutdownTimeout, 270 | ReadOnlyTokens: a.readOnlyTokens, 271 | ReadWriteTokens: a.readWriteTokens, 272 | ReadTimeout: Duration(a.readTimeout), 273 | WriteTimeout: Duration(a.writeTimeout), 274 | } 275 | if a.enableCORS.IsSet() { 276 | configFromFlags.EnableCORS = &a.enableCORS.value 277 | } 278 | if a.enableAuth.IsSet() { 279 | configFromFlags.EnableAuth = &a.enableAuth.value 280 | } 281 | log.Printf("config from flag: %+v", configFromFlags) 282 | if err := mergo.Merge(&config, configFromFlags, mergo.WithOverride); err != nil { 283 | return nil, fmt.Errorf("failed to merge config from flags: %w", err) 284 | } 285 | log.Printf("merged flag config: %+v", config) 286 | 287 | v := config.AsConfig() 288 | return &v, nil 289 | } 290 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | go-simple-upload-server 2 | ======================= 3 | 4 | Simple HTTP server to save artifacts 5 | 6 | - [Usage](#usage) 7 | - [Authentication](#authentication) 8 | - [TLS](#tls) 9 | - [Timeouts](#timeouts) 10 | - [Testing](#testing) 11 | - [API](#api) 12 | - [`POST /upload`](#post-upload) 13 | - [`PUT /files/:path`](#put-filespath) 14 | - [`GET /files/:path`](#get-filespath) 15 | - [`HEAD /files/:path`](#head-filespath) 16 | - [`OPTIONS /files/:path`](#options-filespath) 17 | - [`OPTIONS /upload`](#options-upload) 18 | 19 | 20 | ## Usage 21 | 22 | ``` 23 | -addr string 24 | address to listen 25 | -config string 26 | path to config file 27 | -document_root string 28 | path to document root directory 29 | -enable_auth 30 | enable authentication 31 | -enable_cors 32 | enable CORS header 33 | -file_naming_strategy string 34 | File naming strategy 35 | -max_upload_size int 36 | max upload size in bytes 37 | -read_only_tokens value 38 | comma separated list of read only tokens 39 | -read_timeout duration 40 | read timeout. zero or negative value means no timeout. can be suffixed by the time units 'ns', 'us' (or 'µs'), 'ms', 's', 'm', 'h' (e.g. '1s', '500ms'). If no suffix is provided, it is interpreted as seconds. 41 | -read_write_tokens value 42 | comma separated list of read write tokens 43 | -shutdown_timeout int 44 | graceful shutdown timeout in milliseconds 45 | -write_timeout duration 46 | write timeout. zero or negative value means no timeout. same format as read_timeout. 47 | ``` 48 | 49 | Configurations via the arguments take precedence over those came from the config file. 50 | 51 | ## Authentication 52 | 53 | This server does not require authentication by default. Anyone who can access the server can get/upload files. 54 | 55 | The server implements a simple authentication mechanism using tokens. 56 | 57 | 1. Configure the server to enable authentication: `"enable_auth": true` or `-enable_auth=true`. 58 | 2. Prepare tokens. Any string value is valid as a token. 59 | 3. Add them to the configuration. There are two keys in configuration: `read_only_tokens` and `read_write_tokens`. 60 | If authentication is enabled but no tokens provided, the server generates a read-only token and a read-write token on its starting up. 61 | 4. Request with the token. Add Authorization header with value `Bearer ` or `token=` to the query parameter. Authorization header takes precedence. 62 | 63 | | Token Type | Allowed Operations | 64 | | ---------- | ------------------------------------------ | 65 | | read-only | `GET`, `HEAD` | 66 | | read-write | `POST`, `PUT` in addition to read-only ops | 67 | 68 | Note that `OPTIONS` is always allowed without authentication. 69 | 70 | Authentication is failed when: 71 | 72 | * A request has no tokens. 73 | * A request has a token but not registered to the server. 74 | * A request has a token but not allowed to the requested operation. 75 | 76 | In these cases, the server respond with `401 Unauthorized` with body like as: `{"ok": false, "error": "unauthorized"}`. 77 | 78 | No one can request write operations if you configures the server with read-only tokens only. 79 | As a result, the server operates like read-only mode. 80 | 81 | ## TLS 82 | 83 | v1 has TLS support but I decided to omit it from v2. 84 | 85 | Please consider using a reverse proxy like nginx. 86 | 87 | ## Timeouts 88 | 89 | (Since v2.1.0) 90 | 91 | There are 2 timeout configurations: read and write. 92 | The terms "read" and "write" are from the server's perspective. From clients, they are "upload" (`POST`/`PUT`) and "download" (`GET`) respectively. 93 | 94 | Read timeout (`-read_timeout`) is the maximum duration for the server reading the request. 95 | Clients should finish sending request headers and the entire content within this timeout. 96 | This is set to 15 seconds by default. 97 | 98 | Write timeout (`-write_timeout`) is the maximum duration for the server writing the response. 99 | Clients should finish downloading the content within this timeout. 100 | This timeout is not set by default. Before v2.1.0, this is set to 15 seconds. 101 | 102 | Please consider changing these timeout if: 103 | 104 | * the server or the clients are in a low-bandwidth network. 105 | * you are working with large files. 106 | 107 | Note that a longer timeout will result in more connections being maintained. 108 | 109 | ## Testing 110 | 111 | To run all tests, just run `go test` as usual: 112 | 113 | ``` 114 | $ go test ./... 115 | ``` 116 | 117 | This includes end-to-end tests. By default, the server with on-memory FileSystem is created and it starts listening on 118 | the port chosen randomly. You can control this behavior by setting the environment variables. 119 | 120 | If `TEST_WITH_REAL_FS=${PATH_TO_DOCUMENT_ROOT}` is set, the test server uses the real filesystem. Make sure the document 121 | root directory contains no files; otherwise, some tests might be failed. The directory will not be cleaned after testing. 122 | 123 | If `TEST_TARGET_ADDR-${HOST}:${PORT}` is set, the test program doesn't start a local test server and sends requests to 124 | `http://${HOST}:${PORT}`. Note that the target server's document root should be cleared prior to testing. 125 | 126 | This repository has `docker-compose.e2e.yml` to run the E2E test. To run tests using this: 127 | 128 | ``` 129 | $ docker compose -f docker-compose.e2e.yml run --rm test 130 | $ docker compose -f docker-compose.e2e.yml down --rmi local --volumes 131 | ``` 132 | 133 | ## API 134 | 135 | ### `POST /upload` 136 | 137 | Uploads a new file. The name of the local (= server-side) file is taken from the uploading file. 138 | 139 | #### Request 140 | 141 | Content-Type 142 | : `multipart/form-data` 143 | 144 | Parameters: 145 | 146 | | Name | Required? | Type | Description | Default | 147 | | ----------- | :-------: | --------- | ------------------------------------------------------------ | ------- | 148 | | `file` | x | Form Data | A content of the file. | | 149 | | `overwrite` | | `boolean` | Allow overwriting the existing file on the server if `true`. | `false` | 150 | 151 | #### Response 152 | 153 | ##### On Successful 154 | 155 | Status Code 156 | : `201 Created` 157 | 158 | Content-Type 159 | : `application/json` 160 | 161 | Body: 162 | 163 | | Name | Type | Description | 164 | | ------ | --------- | --------------------------------------- | 165 | | `ok` | `boolean` | `true` if successful. | 166 | | `path` | `string` | A path to access this file in this API. | 167 | 168 | ##### On Failure 169 | 170 | | StatusCode | When | 171 | | -------------- | ---------------------------------------------------------------------------------------------- | 172 | | `409 Conflict` | There is the file whose name is the same as the uploading file and overwriting is not allowed. | 173 | 174 | #### Example 175 | 176 | ``` 177 | $ echo 'Hello, world!' > sample.txt 178 | $ curl -Ffile=@sample.txt http://localhost:25478/upload 179 | {"ok":true,"path":"/files/sample.txt"} 180 | ``` 181 | 182 | ``` 183 | $ cat $DOCROOT/sample.txt 184 | Hello, world! 185 | ``` 186 | 187 | ### `PUT /files/:path` 188 | 189 | Uploads a file. The original file name is ignored and the name is taken from the path in the request URL. 190 | 191 | #### Parameters 192 | 193 | | Name | Required? | Type | Description | Default | 194 | | ----------- | :-------: | --------- | -------------------------------------------------- | ------- | 195 | | `:path` | x | `string` | Path to the file. | | 196 | | `file` | x | Form Data | A content of the file. | | 197 | | `overwrite` | | `boolean` | Allow overwriting the existing file on the server. | `false` | 198 | 199 | #### Response 200 | 201 | ##### On Successful 202 | 203 | Status Code 204 | : `201 Created` 205 | 206 | Content-Type 207 | : `application/json` 208 | 209 | Body: 210 | 211 | | Name | Type | Description | 212 | | ------ | --------- | --------------------------------------- | 213 | | `ok` | `boolean` | `true` if successful. | 214 | | `path` | `string` | A path to access this file in this API. | 215 | 216 | ##### On Failure 217 | 218 | | StatusCode | When | 219 | | -------------- | ---------------------------------------------------------------------------------------------- | 220 | | `409 Conflict` | There is the file whose name is the same as the uploading file and overwriting is not allowed. | 221 | 222 | #### Example 223 | 224 | ``` 225 | $ curl -XPUT -Ffile=@sample.txt "http://localhost:25478/files/foobar.txt" 226 | {"ok":true,"path":"/files/foobar.txt"} 227 | 228 | $ cat $DOCROOT/foobar.txt 229 | Hello, world! 230 | ``` 231 | 232 | ### `GET /files/:path` 233 | 234 | Downloads a file. 235 | 236 | #### Request 237 | 238 | Parameters: 239 | 240 | | Name | Required? | Type | Description | Default | 241 | | ------ | :-------: | -------- | ------------------- | ------- | 242 | | `path` | x | `string` | A path to the file. | | 243 | 244 | #### Response 245 | 246 | ##### On Successful 247 | 248 | Status Code 249 | : `200 OK` 250 | 251 | Content-Type 252 | : Depends on the content. 253 | 254 | Body 255 | : The content of the request file. 256 | 257 | ##### On Failure 258 | 259 | Content-Type 260 | : `application/json` 261 | 262 | | StatusCode | When | 263 | | --------------- | ---------------------- | 264 | | `404 Not Found` | There is no such file. | 265 | 266 | #### Example 267 | 268 | ``` 269 | $ curl http://localhost:25478/files/sample.txt 270 | Hello, world! 271 | ``` 272 | 273 | ### `HEAD /files/:path` 274 | 275 | Check existence of a file. 276 | 277 | #### Request 278 | 279 | Parameters: 280 | 281 | | Name | Required? | Type | Description | Default | 282 | | ------ | :-------: | -------- | ------------------- | ------- | 283 | | `path` | x | `string` | A path to the file. | | 284 | 285 | #### Response 286 | 287 | ##### On Successful 288 | 289 | Status Code 290 | : `200 OK` 291 | 292 | Body 293 | : Not Available 294 | 295 | ##### On Failure 296 | 297 | | StatusCode | When | 298 | | -------------- | ---------------------------------------------------------------------------------------------- | 299 | | `404 Not Found` | No such file on the server. | 300 | 301 | #### Example 302 | 303 | ``` 304 | $ curl -I http://localhost:25478/files/foobar.txt 305 | ``` 306 | 307 | ### `OPTIONS /files/:path` 308 | ### `OPTIONS /upload` 309 | 310 | CORS preflight request. 311 | 312 | #### Request 313 | 314 | Parameters: 315 | 316 | | Name | Required? | Type | Description | Default | 317 | | ------ | :-------: | -------- | ------------------- | ------- | 318 | | `path` | x | `string` | A path to the file. | | 319 | 320 | #### Response 321 | 322 | ##### On Successful 323 | 324 | Status Code 325 | : `204 No Content` 326 | 327 | ##### On Failure 328 | 329 | #### Example 330 | 331 | TODO 332 | 333 | #### Notes 334 | 335 | * Requests using `*` as a path, like as `OPTIONS * HTTP/1.1`, are not supported. 336 | * On sending `OPTIONS` request, `token` parameter is not required. 337 | * For `/files/:path` request, server replies "204 No Content" even if the specified file does not exist. 338 | -------------------------------------------------------------------------------- /pkg/server_test.go: -------------------------------------------------------------------------------- 1 | package simpleuploadserver 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "mime/multipart" 7 | "net/http" 8 | "net/http/httptest" 9 | "path" 10 | "path/filepath" 11 | "reflect" 12 | "strings" 13 | "testing" 14 | 15 | "github.com/spf13/afero" 16 | ) 17 | 18 | func TestGetHandler(t *testing.T) { 19 | type args struct { 20 | Method string 21 | Url string 22 | } 23 | tests := []struct { 24 | name string 25 | args args 26 | want int 27 | body string 28 | headers map[string]string 29 | }{ 30 | { 31 | name: "get existing file", 32 | args: args{ 33 | Method: http.MethodGet, 34 | Url: "/files/foo/bar.txt", 35 | }, 36 | want: http.StatusOK, 37 | headers: map[string]string{ 38 | "Access-Control-Allow-Origin": "*", 39 | }, 40 | body: "hello, world", 41 | }, 42 | { 43 | name: "get non-existing file", 44 | args: args{ 45 | Method: http.MethodGet, 46 | Url: "/files/bar/baz", 47 | }, 48 | want: http.StatusNotFound, 49 | headers: map[string]string{ 50 | "Access-Control-Allow-Origin": "*", 51 | }, 52 | body: `{"ok":false,"error":"file not found"}`, 53 | }, 54 | { 55 | name: "get without endpoint", 56 | args: args{ 57 | Method: http.MethodGet, 58 | Url: "/abc", 59 | }, 60 | want: http.StatusNotFound, 61 | body: `{"ok":false,"error":"file not found"}`, 62 | }, 63 | { 64 | name: "get directory", 65 | args: args{ 66 | Method: http.MethodGet, 67 | Url: "/files/foo", 68 | }, 69 | want: http.StatusNotFound, 70 | headers: map[string]string{ 71 | "Access-Control-Allow-Origin": "*", 72 | }, 73 | body: `{"ok":false,"error":"foo is a directory"}`, 74 | }, 75 | } 76 | for _, tt := range tests { 77 | t.Run(tt.name, func(t *testing.T) { 78 | docRoot := "/opt/app" 79 | fs := afero.NewMemMapFs() 80 | if err := fs.MkdirAll(path.Join(docRoot, "foo"), 0755); err != nil { 81 | t.Fatal(err) 82 | } 83 | if err := afero.WriteFile(fs, path.Join(docRoot, "foo", "bar.txt"), []byte("hello, world"), 0644); err != nil { 84 | t.Fatal(err) 85 | } 86 | config := ServerConfig{ 87 | DocumentRoot: "/opt/app", 88 | EnableCORS: true, 89 | } 90 | server := Server{config, afero.NewBasePathFs(fs, docRoot)} 91 | req, err := http.NewRequest(tt.args.Method, tt.args.Url, nil) 92 | if err != nil { 93 | t.Fatal(err) 94 | } 95 | 96 | rr := httptest.NewRecorder() 97 | handler := http.HandlerFunc(server.handle(server.handleGet)) 98 | handler.ServeHTTP(rr, req) 99 | 100 | if status := rr.Code; status != tt.want { 101 | t.Errorf("status = %d, want = %d", status, tt.want) 102 | } 103 | if body := rr.Body.String(); body != tt.body { 104 | t.Errorf("body = \"%s\", want = \"%s\"", body, tt.body) 105 | } 106 | for k, v := range tt.headers { 107 | if rr.Header().Get(k) != v { 108 | t.Errorf("header %s = %s, want %s", k, rr.Header().Get(k), v) 109 | } 110 | } 111 | }) 112 | } 113 | } 114 | 115 | func TestServer_PostHandler(t *testing.T) { 116 | docRoot := "/opt/app" 117 | type args struct { 118 | Method string 119 | Url string 120 | Content []byte 121 | Name string 122 | } 123 | tests := []struct { 124 | name string 125 | args args 126 | want int 127 | body string 128 | headers map[string]string 129 | }{ 130 | { 131 | name: "Post hello.txt", 132 | args: args{ 133 | Method: http.MethodPost, 134 | Url: "/upload", 135 | Content: []byte("hello, world"), 136 | Name: "hello.txt", 137 | }, 138 | want: http.StatusCreated, 139 | headers: map[string]string{ 140 | "Access-Control-Allow-Origin": "*", 141 | }, 142 | body: `{"ok":true,"path":"/files/hello.txt"}`, 143 | }, 144 | { 145 | name: "Post nothing", 146 | args: args{ 147 | Method: http.MethodPost, 148 | Url: "/upload", 149 | Content: []byte{}, 150 | Name: "empty", 151 | }, 152 | want: http.StatusCreated, 153 | headers: map[string]string{ 154 | "Access-Control-Allow-Origin": "*", 155 | }, 156 | body: `{"ok":true,"path":"/files/empty"}`, 157 | }, 158 | { 159 | name: "Post the existing file should be rejected", 160 | args: args{ 161 | Method: http.MethodPost, 162 | Url: "/upload", 163 | Content: []byte("overwritten!"), 164 | Name: "ow.txt", 165 | }, 166 | want: http.StatusConflict, 167 | body: `{"ok":false,"error":"the file already exists"}`, 168 | }, 169 | { 170 | name: "Post the existing file with overwrite option should be accepted", 171 | args: args{ 172 | Method: http.MethodPost, 173 | Url: "/upload?overwrite=true", 174 | Content: []byte("overwritten!"), 175 | Name: "ow.txt", 176 | }, 177 | want: http.StatusCreated, 178 | body: `{"ok":true,"path":"/files/ow.txt"}`, 179 | }, 180 | { 181 | name: "POST large file should fail", 182 | args: args{ 183 | Method: http.MethodPost, 184 | Url: "/upload", 185 | Content: []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), 186 | Name: "toolarge", 187 | }, 188 | want: http.StatusRequestEntityTooLarge, 189 | body: `{"ok":false,"error":"file size limit exceeded"}`, 190 | }, 191 | // TODO: add text without name 192 | } 193 | for _, tt := range tests { 194 | t.Run(tt.name, func(t *testing.T) { 195 | fs := afero.NewMemMapFs() 196 | if err := fs.MkdirAll(docRoot, 0755); err != nil { 197 | t.Fatal(err) 198 | } 199 | if err := afero.WriteFile(fs, path.Join(docRoot, "ow.txt"), []byte("overwrite?"), 0644); err != nil { 200 | t.Fatal(err) 201 | } 202 | config := ServerConfig{ 203 | DocumentRoot: docRoot, 204 | EnableCORS: true, 205 | MaxUploadSize: 16, 206 | } 207 | server := Server{config, afero.NewBasePathFs(fs, docRoot)} 208 | 209 | b := new(bytes.Buffer) 210 | w := multipart.NewWriter(b) 211 | fw, err := w.CreateFormFile("file", tt.args.Name) 212 | if err != nil { 213 | t.Fatal(err) 214 | } 215 | written, err := fw.Write(tt.args.Content) 216 | if err != nil { 217 | t.Fatal(err) 218 | } 219 | if written == 0 && len(tt.args.Content) > 0 { 220 | t.Fatalf("content has %d bytes but no bytes written", len(tt.args.Content)) 221 | } 222 | w.Close() 223 | 224 | req, err := http.NewRequest(tt.args.Method, tt.args.Url, b) 225 | if err != nil { 226 | t.Fatal(err) 227 | } 228 | req.Header.Set("Content-Type", w.FormDataContentType()) 229 | 230 | rr := httptest.NewRecorder() 231 | handler := http.HandlerFunc(server.handle(server.handlePost)) 232 | handler.ServeHTTP(rr, req) 233 | 234 | if status := rr.Code; status != tt.want { 235 | t.Errorf("status = %d, want = %d", status, tt.want) 236 | t.Logf("%+v", req) 237 | } 238 | if body := rr.Body.String(); body != tt.body { 239 | t.Errorf("body = \"%s\", want = \"%s\"", body, tt.body) 240 | } 241 | if rr.Code == http.StatusCreated { 242 | var resp struct { 243 | OK bool `json:"ok"` 244 | Path string `json:"path"` 245 | } 246 | if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { 247 | t.Errorf("unexpected response body: %v", err) 248 | } 249 | localPath := strings.TrimPrefix(resp.Path, "/files/") 250 | localFullPath := filepath.Join(docRoot, localPath) 251 | f, err := fs.Open(localFullPath) 252 | if err != nil { 253 | t.Errorf("failed to verify local file. cannot open %s: %v", localFullPath, err) 254 | return 255 | } 256 | defer f.Close() 257 | uploaded, err := afero.ReadAll(f) 258 | if err != nil { 259 | t.Errorf("failed to verify local file. cannot read %s: %v", localFullPath, err) 260 | return 261 | } 262 | if !reflect.DeepEqual(uploaded, tt.args.Content) { 263 | t.Errorf("failed to verify. request body = %v, local file = %v", tt.args.Content, uploaded) 264 | } 265 | } 266 | for k, v := range tt.headers { 267 | if rr.Header().Get(k) != v { 268 | t.Errorf("header %s = %s, want %s", k, rr.Header().Get(k), v) 269 | } 270 | } 271 | }) 272 | } 273 | } 274 | 275 | func TestServer_PutHandler(t *testing.T) { 276 | docRoot := "/opt/app" 277 | type args struct { 278 | Method string 279 | Url string 280 | Content []byte 281 | Name string 282 | } 283 | tests := []struct { 284 | name string 285 | args args 286 | want int 287 | body string 288 | headers map[string]string 289 | }{ 290 | { 291 | name: "PUT /files/hello.txt with text", 292 | args: args{ 293 | Method: http.MethodPut, 294 | Url: "/files/hello.txt", 295 | Content: []byte("hello, world"), 296 | Name: "hello.txt", 297 | }, 298 | want: http.StatusCreated, 299 | headers: map[string]string{ 300 | "Access-Control-Allow-Origin": "*", 301 | }, 302 | body: `{"ok":true,"path":"/files/hello.txt"}`, 303 | }, 304 | { 305 | name: "PUT /files/empty with an empty content", 306 | args: args{ 307 | Method: http.MethodPut, 308 | Url: "/files/empty", 309 | Content: []byte{}, 310 | Name: "empty", 311 | }, 312 | want: http.StatusCreated, 313 | headers: map[string]string{ 314 | "Access-Control-Allow-Origin": "*", 315 | }, 316 | body: `{"ok":true,"path":"/files/empty"}`, 317 | }, 318 | { 319 | name: "PUT /files/hello/world.txt will create directory and file", 320 | args: args{ 321 | Method: http.MethodPut, 322 | Url: "/files/hello/world.txt", 323 | Content: []byte("hello, world"), 324 | Name: "world.txt", 325 | }, 326 | want: http.StatusCreated, 327 | headers: map[string]string{ 328 | "Access-Control-Allow-Origin": "*", 329 | }, 330 | body: `{"ok":true,"path":"/files/hello/world.txt"}`, 331 | }, 332 | { 333 | name: "PUT /files/ should fail", 334 | args: args{ 335 | Method: http.MethodPut, 336 | Url: "/files/", 337 | Content: []byte("hello"), 338 | Name: "hello", 339 | }, 340 | want: http.StatusMethodNotAllowed, 341 | body: `{"ok":false,"error":"PUT is accepted on /files/:name"}`, 342 | }, 343 | { 344 | name: "PUT large file should fail", 345 | args: args{ 346 | Method: http.MethodPut, 347 | Url: "/files/toolarge", 348 | Content: []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), 349 | Name: "toolarge", 350 | }, 351 | want: http.StatusRequestEntityTooLarge, 352 | body: `{"ok":false,"error":"file size limit exceeded"}`, 353 | }, 354 | // TODO: add text without name 355 | } 356 | for _, tt := range tests { 357 | t.Run(tt.name, func(t *testing.T) { 358 | fs := afero.NewMemMapFs() 359 | if err := fs.MkdirAll(docRoot, 0755); err != nil { 360 | t.Fatal(err) 361 | } 362 | config := ServerConfig{ 363 | DocumentRoot: docRoot, 364 | EnableCORS: true, 365 | MaxUploadSize: 16, 366 | } 367 | server := Server{config, afero.NewBasePathFs(fs, docRoot)} 368 | 369 | b := new(bytes.Buffer) 370 | w := multipart.NewWriter(b) 371 | fw, err := w.CreateFormFile("file", tt.args.Name) 372 | if err != nil { 373 | t.Fatal(err) 374 | } 375 | written, err := fw.Write(tt.args.Content) 376 | if err != nil { 377 | t.Fatal(err) 378 | } 379 | if written == 0 && len(tt.args.Content) > 0 { 380 | t.Fatalf("content has %d bytes but no bytes written", len(tt.args.Content)) 381 | } 382 | w.Close() 383 | 384 | req, err := http.NewRequest(tt.args.Method, tt.args.Url, b) 385 | if err != nil { 386 | t.Fatal(err) 387 | } 388 | req.Header.Set("Content-Type", w.FormDataContentType()) 389 | 390 | rr := httptest.NewRecorder() 391 | handler := http.HandlerFunc(server.handle(server.handlePut)) 392 | handler.ServeHTTP(rr, req) 393 | 394 | if status := rr.Code; status != tt.want { 395 | t.Errorf("status = %d, want = %d", status, tt.want) 396 | } 397 | if body := rr.Body.String(); body != tt.body { 398 | t.Errorf("body = \"%s\", want = \"%s\"", body, tt.body) 399 | } 400 | if rr.Code == http.StatusCreated || rr.Code == http.StatusOK { 401 | var resp struct { 402 | OK bool `json:"ok"` 403 | Path string `json:"path"` 404 | } 405 | if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { 406 | t.Errorf("unexpected response body: %v", err) 407 | } 408 | localPath := strings.TrimPrefix(resp.Path, "/files/") 409 | localFullPath := filepath.Join(docRoot, localPath) 410 | t.Logf("opening %s to verify", localFullPath) 411 | f, err := fs.Open(localFullPath) 412 | if err != nil { 413 | t.Errorf("failed to verify local file. cannot open %s: %v", localFullPath, err) 414 | return 415 | } 416 | defer f.Close() 417 | uploaded, err := afero.ReadAll(f) 418 | if err != nil { 419 | t.Errorf("failed to verify local file. cannot read %s: %v", localFullPath, err) 420 | return 421 | } 422 | if !reflect.DeepEqual(uploaded, tt.args.Content) { 423 | t.Errorf("failed to verify. request body = %v, local file = %v", tt.args.Content, uploaded) 424 | } 425 | } 426 | for k, v := range tt.headers { 427 | if rr.Header().Get(k) != v { 428 | t.Errorf("header %s = %s, want %s", k, rr.Header().Get(k), v) 429 | } 430 | } 431 | }) 432 | } 433 | } 434 | 435 | func Test_getFileSize(t *testing.T) { 436 | tests := []struct { 437 | name string 438 | content []byte 439 | want int64 440 | wantErr bool 441 | }{ 442 | { 443 | name: "hello, world", 444 | content: []byte("hello, world"), 445 | want: 12, 446 | wantErr: false, 447 | }, 448 | { 449 | name: "empty bytes", 450 | content: []byte(""), 451 | want: 0, 452 | wantErr: false, 453 | }, 454 | } 455 | for _, tt := range tests { 456 | t.Run(tt.name, func(t *testing.T) { 457 | r := bytes.NewReader(tt.content) 458 | got, err := getFileSize(r) 459 | if (err != nil) != tt.wantErr { 460 | t.Errorf("getFileSize() error = %v, wantErr %v", err, tt.wantErr) 461 | return 462 | } 463 | if got != tt.want { 464 | t.Errorf("getFileSize() = %v, want %v", got, tt.want) 465 | } 466 | }) 467 | } 468 | } 469 | 470 | func Test_parseBoolishValue(t *testing.T) { 471 | tests := []struct { 472 | arg string 473 | want bool 474 | }{ 475 | {"yes", true}, 476 | {"true", true}, 477 | {"1", true}, 478 | {"True", true}, 479 | {"", false}, 480 | {"no", false}, 481 | {"foo", false}, 482 | } 483 | for _, tt := range tests { 484 | t.Run(tt.arg, func(t *testing.T) { 485 | if got := parseBoolishValue(tt.arg); got != tt.want { 486 | t.Errorf("parseBoolishValue(%s) = %v, want %v", tt.arg, got, tt.want) 487 | } 488 | }) 489 | } 490 | } 491 | -------------------------------------------------------------------------------- /pkg/server.go: -------------------------------------------------------------------------------- 1 | package simpleuploadserver 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "path/filepath" 15 | "regexp" 16 | "slices" 17 | "strings" 18 | "time" 19 | 20 | "github.com/gorilla/mux" 21 | "github.com/spf13/afero" 22 | ) 23 | 24 | type Server struct { 25 | ServerConfig 26 | fs afero.Fs 27 | } 28 | 29 | var ( 30 | FormFileKey = "file" 31 | OverwriteQueryKey = "overwrite" 32 | ) 33 | 34 | var ( 35 | ErrFileSizeLimitExceeded = fmt.Errorf("file size limit exceeded") 36 | ) 37 | 38 | var ( 39 | DefaultAddr = "127.0.0.1:8080" 40 | ) 41 | 42 | // ServerConfig is a configuration for Server. 43 | type ServerConfig struct { 44 | // Address where the server listens on. 45 | Addr string `json:"addr"` 46 | // Path to the document root. 47 | DocumentRoot string `json:"document_root"` 48 | // Determines whether to enable CORS header. 49 | EnableCORS bool `json:"enable_cors"` 50 | // Maximum upload size in bytes. 51 | MaxUploadSize int64 `json:"max_upload_size"` 52 | // File naming strategy. 53 | FileNamingStrategy string `json:"file_naming_strategy"` 54 | // Graceful shutdown timeout in milliseconds. 55 | ShutdownTimeout int `json:"shutdown_timeout"` 56 | // Enable authentication. 57 | EnableAuth bool `json:"enable_auth"` 58 | // Authentication tokens for read-only access. 59 | ReadOnlyTokens []string `json:"read_only_tokens"` 60 | // Authentication tokens for read-write access. 61 | ReadWriteTokens []string `json:"read_write_tokens"` 62 | // ReadTimeout is the maximum duration (in seconds) for reading the entire request, including the body. Zero or negative value means no timeout. 63 | ReadTimeout time.Duration `json:"read_timeout"` 64 | // WriteTimeout is the maximum duration (in seconds) for writing the response. Zero or negative value means no timeout. 65 | WriteTimeout time.Duration `json:"write_timeout"` 66 | } 67 | 68 | // NewServer creates a new Server. 69 | func NewServer(config ServerConfig) *Server { 70 | return &Server{ 71 | config, 72 | afero.NewBasePathFs(afero.NewOsFs(), config.DocumentRoot), 73 | } 74 | } 75 | 76 | // Start starts listening on `addr`. This function blocks until the server is stopped. 77 | // Optionally you can pass a channel to `ready` to be notified when the server is ready to accept connections. You can pass nil if you don't need it. 78 | func (s *Server) Start(ctx context.Context, ready chan struct{}) error { 79 | r := mux.NewRouter() 80 | r.HandleFunc("/upload", s.handle(s.handlePost)).Methods(http.MethodPost) 81 | r.HandleFunc("/upload", s.handle(s.handleOptions)).Methods(http.MethodOptions) 82 | // GET handler can handle HEAD request. The difference is that the response body should be empty on HEAD request. 83 | r.PathPrefix("/files").Methods(http.MethodGet, http.MethodHead).HandlerFunc(s.handle(s.handleGet)) 84 | r.PathPrefix("/files").Methods(http.MethodPut).HandlerFunc(s.handle(s.handlePut)) 85 | r.PathPrefix("/files").Methods(http.MethodOptions).HandlerFunc(s.handle(s.handleOptions)) 86 | r.NotFoundHandler = http.HandlerFunc(handleNotFound) 87 | r.MethodNotAllowedHandler = http.HandlerFunc(handleMethodNotAllowed) 88 | if s.EnableAuth { 89 | r.Use(s.authenticationMiddleware) 90 | } 91 | r.Use(logAccess) 92 | 93 | addr := s.Addr 94 | if addr == "" { 95 | addr = DefaultAddr 96 | } 97 | log.Printf("Start listening on %s", addr) 98 | l, err := net.Listen("tcp", addr) 99 | if err != nil { 100 | return fmt.Errorf("unable to listen on %s: %v", addr, err) 101 | } 102 | if ready != nil { 103 | close(ready) 104 | } 105 | 106 | srv := &http.Server{ 107 | Addr: addr, 108 | WriteTimeout: s.WriteTimeout, 109 | ReadTimeout: s.ReadTimeout, 110 | IdleTimeout: 60 * time.Second, 111 | Handler: r, 112 | } 113 | 114 | ret := make(chan error, 1) 115 | go func() { 116 | log.Printf("Start serving on %s", addr) 117 | ret <- srv.Serve(l) 118 | }() 119 | 120 | <-ctx.Done() 121 | log.Printf("Shutting down... wait up to %d ms", s.ShutdownTimeout) 122 | sctx, cancel := context.WithTimeout(context.Background(), time.Duration(s.ShutdownTimeout)*time.Millisecond) 123 | defer cancel() 124 | if err := srv.Shutdown(sctx); err != nil { 125 | log.Printf("failed to shutdown gracefully: %v", err) 126 | } 127 | err = <-ret 128 | return err 129 | } 130 | 131 | func logAccess(next http.Handler) http.Handler { 132 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 133 | vs := []string{ 134 | r.RemoteAddr, 135 | "-", 136 | "-", 137 | time.Now().Format("[02/Jan/2006:15:04:05 -0700]"), 138 | fmt.Sprintf("\"%s %s %s\"", r.Method, r.URL.Path, r.Proto), 139 | fmt.Sprintf("%d", http.StatusOK), // TODO: actual status 140 | "0", // TODO: actual size 141 | fmt.Sprintf("\"%s\"", r.Referer()), 142 | fmt.Sprintf("\"%s\"", r.UserAgent()), 143 | } 144 | log.Println(strings.Join(vs, " ")) 145 | next.ServeHTTP(w, r) 146 | }) 147 | } 148 | 149 | var fileRe = regexp.MustCompile(`^/files/(.+)$`) 150 | 151 | func getPathFromURL(u *url.URL) string { 152 | matches := fileRe.FindStringSubmatch(u.Path) 153 | if matches == nil { 154 | return "" 155 | } 156 | return matches[1] 157 | } 158 | 159 | type ErrorResult struct { 160 | OK bool `json:"ok"` 161 | Error string `json:"error"` 162 | } 163 | 164 | type SuccessfullyUploadedResult struct { 165 | OK bool `json:"ok"` 166 | Path string `json:"path"` 167 | } 168 | 169 | func justOK() (int, any) { 170 | return 0, nil 171 | } 172 | 173 | func (s *Server) handle(f func(w http.ResponseWriter, r *http.Request) (int, any)) http.HandlerFunc { 174 | return func(w http.ResponseWriter, r *http.Request) { 175 | status, result := f(w, r) 176 | var responseBody []byte 177 | if result != nil { 178 | switch v := result.(type) { 179 | case error: 180 | result = ErrorResult{false, v.Error()} 181 | } 182 | respBytes, err := json.Marshal(result) 183 | if err != nil { 184 | log.Printf("failed to encode response: %v", err) 185 | w.WriteHeader(http.StatusInternalServerError) 186 | return 187 | } 188 | responseBody = respBytes 189 | } 190 | if responseBody != nil { 191 | w.Header().Set("Content-Type", "application/json") 192 | if status != 0 { 193 | w.WriteHeader(status) 194 | } 195 | if _, err := w.Write(responseBody); err != nil { 196 | log.Printf("failed to write response: %v", err) 197 | } 198 | } else { 199 | if status != 0 { 200 | w.WriteHeader(status) 201 | } 202 | } 203 | } 204 | } 205 | 206 | func (s *Server) handlePost(w http.ResponseWriter, r *http.Request) (int, any) { 207 | status, destPath, err := s.processUpload(w, r, "") 208 | if err != nil { 209 | return status, err 210 | } 211 | if s.EnableCORS { 212 | w.Header().Set("Access-Control-Allow-Origin", "*") 213 | } 214 | return http.StatusCreated, SuccessfullyUploadedResult{true, destPath} 215 | } 216 | 217 | func (s *Server) handlePut(w http.ResponseWriter, r *http.Request) (int, any) { 218 | path := getPathFromURL(r.URL) 219 | if path == "" { 220 | log.Printf("URL not matched: (url=%s)", r.URL.String()) 221 | return http.StatusMethodNotAllowed, fmt.Errorf("PUT is accepted on /files/:name") 222 | } 223 | 224 | status, destPath, err := s.processUpload(w, r, path) 225 | if err != nil { 226 | return status, err 227 | } 228 | 229 | if s.EnableCORS { 230 | w.Header().Set("Access-Control-Allow-Origin", "*") 231 | } 232 | return http.StatusCreated, SuccessfullyUploadedResult{true, destPath} 233 | } 234 | 235 | func (s *Server) processUpload(w http.ResponseWriter, r *http.Request, path string) (int, string, error) { 236 | allowOverwrite := parseBoolishValue(r.URL.Query().Get(OverwriteQueryKey)) 237 | if allowOverwrite { 238 | log.Printf("allowOverwrite") 239 | } 240 | 241 | srcFile, info, err := r.FormFile(FormFileKey) 242 | if err != nil { 243 | log.Printf("failed to obtain form file: %v", err) 244 | return http.StatusInternalServerError, "", fmt.Errorf("cannot obtain the uploaded content") 245 | } 246 | src := http.MaxBytesReader(w, srcFile, s.MaxUploadSize) 247 | // MaxBytesReader closes the underlying io.Reader on its Close() is called 248 | defer src.Close() 249 | 250 | // on POST method request 251 | if path == "" { 252 | filename := info.Filename 253 | if filename == "" { 254 | namer := ResolveFileNamingStrategy(s.FileNamingStrategy) 255 | s, err := namer(srcFile, info) 256 | if err != nil { 257 | log.Printf("cannot generate filename: %v", err) 258 | return http.StatusInternalServerError, "", fmt.Errorf("cannot generate filename") 259 | } 260 | filename = s 261 | } 262 | path = "/" + filename 263 | } 264 | 265 | if exists, err := afero.Exists(s.fs, path); err != nil { 266 | log.Printf("failed to check the existence of the file (path=%s): %v", path, err) 267 | return http.StatusInternalServerError, "", fmt.Errorf("cannot check the existence of the file") 268 | } else if exists && !allowOverwrite { 269 | return http.StatusConflict, "", fmt.Errorf("the file already exists") 270 | } 271 | 272 | // ensure the directories exist 273 | dirsPath := filepath.Dir(path) 274 | if err := s.fs.MkdirAll(dirsPath, 0755); err != nil { 275 | log.Printf("failed to create directories (path=%s): %v", dirsPath, err) 276 | return http.StatusInternalServerError, "", fmt.Errorf("cannot create directories") 277 | } 278 | 279 | dstFile, err := s.fs.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666) 280 | if err != nil { 281 | log.Printf("failed to open the destination file (path=%s): %v", path, err) 282 | return http.StatusInternalServerError, "", fmt.Errorf("cannot open file") 283 | } 284 | defer dstFile.Close() 285 | written, err := io.Copy(dstFile, src) 286 | if err != nil { 287 | var maxBytesError *http.MaxBytesError 288 | if errors.As(err, &maxBytesError) { 289 | return http.StatusRequestEntityTooLarge, "", ErrFileSizeLimitExceeded 290 | } 291 | log.Printf("failed to write the uploaded content: %v", err) 292 | return http.StatusInternalServerError, "", fmt.Errorf("failed to write the content") 293 | } 294 | log.Printf("uploaded to %s (%d bytes)", path, written) 295 | 296 | destPath := path 297 | if !strings.HasPrefix(destPath, "/") { 298 | destPath = "/" + destPath 299 | } 300 | destPath = "/files" + destPath 301 | 302 | log.Printf("uploaded by PUT to %s (%d bytes)", path, written) 303 | if s.EnableCORS { 304 | w.Header().Set("Access-Control-Allow-Origin", "*") 305 | } 306 | return http.StatusCreated, destPath, nil 307 | } 308 | 309 | func (s *Server) handleGet(w http.ResponseWriter, r *http.Request) (int, any) { 310 | requestPath := getPathFromURL(r.URL) 311 | if requestPath == "" { 312 | return http.StatusNotFound, fmt.Errorf("file not found") 313 | } 314 | log.Printf("GET %s -> %s", r.URL.Path, requestPath) 315 | f, err := s.fs.Open(requestPath) 316 | if s.EnableCORS { 317 | w.Header().Set("Access-Control-Allow-Origin", "*") 318 | } 319 | if err != nil { 320 | // ErrNotExist is a common case so don't log it 321 | if errors.Is(err, os.ErrNotExist) { 322 | return http.StatusNotFound, fmt.Errorf("file not found") 323 | } 324 | log.Printf("Error: %+v", err) 325 | return http.StatusInternalServerError, fmt.Errorf("failed to open file") 326 | } 327 | defer f.Close() 328 | fi, err := f.Stat() 329 | if err != nil { 330 | log.Printf("failed to stat: %v", err) 331 | return http.StatusInternalServerError, fmt.Errorf("stat failed") 332 | } 333 | if fi.IsDir() { 334 | // TODO 335 | log.Printf("%s is a directory", requestPath) 336 | return http.StatusNotFound, fmt.Errorf("%s is a directory", requestPath) 337 | } 338 | name := fi.Name() 339 | modtime := fi.ModTime() 340 | http.ServeContent(w, r, name, modtime, f) 341 | return justOK() 342 | } 343 | 344 | func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request) (int, any) { 345 | var allowedMethods []string 346 | if r.URL.Path == "/upload" { 347 | allowedMethods = []string{http.MethodPost} 348 | } else if strings.HasPrefix(r.URL.Path, "/files") { 349 | allowedMethods = []string{http.MethodGet, http.MethodPut, http.MethodHead} 350 | } 351 | if s.EnableCORS { 352 | w.Header().Set("Access-Control-Allow-Origin", "*") 353 | } 354 | w.Header().Set("Access-Control-Allow-Methods", strings.Join(allowedMethods, ", ")) 355 | return http.StatusNoContent, nil 356 | } 357 | 358 | func (s *Server) authenticationMiddleware(next http.Handler) http.Handler { 359 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 360 | // OPTIONS request is always allowed without authentication 361 | if r.Method == http.MethodOptions { 362 | next.ServeHTTP(w, r) 363 | return 364 | } 365 | 366 | var token string 367 | if auth := r.Header.Get("Authorization"); auth != "" { 368 | token = strings.TrimPrefix(auth, "Bearer ") 369 | } else if t := r.URL.Query().Get("token"); t != "" { 370 | token = t 371 | } 372 | if token == "" { 373 | log.Printf("no token") 374 | writeUnauthorized(w, r) 375 | return 376 | } 377 | var allowedTokens []string 378 | allowedTokens = append(allowedTokens, s.ReadWriteTokens...) 379 | if r.Method == http.MethodGet || r.Method == http.MethodHead { 380 | allowedTokens = append(allowedTokens, s.ReadOnlyTokens...) 381 | } 382 | if !slices.Contains(allowedTokens, token) { 383 | log.Printf("invalid token") 384 | writeUnauthorized(w, r) 385 | return 386 | } 387 | log.Print("successfully authenticated") 388 | r.Header.Del("Authorization") 389 | u := r.URL 390 | q := u.Query() 391 | q.Del("token") 392 | u.RawQuery = q.Encode() 393 | r.URL = u 394 | next.ServeHTTP(w, r) 395 | }) 396 | } 397 | 398 | func writeUnauthorized(w http.ResponseWriter, r *http.Request) { 399 | w.Header().Set("WWW-Authenticate", "Bearer") 400 | if r.Method != http.MethodHead { 401 | w.Header().Set("Content-Type", "application/json") 402 | } 403 | w.WriteHeader(http.StatusUnauthorized) 404 | if r.Method == http.MethodHead { 405 | return 406 | } 407 | resp := ErrorResult{false, "unauthorized"} 408 | respBytes, err := json.Marshal(resp) 409 | if err != nil { 410 | log.Printf("failed to encode response: %v", err) 411 | return 412 | } 413 | if _, err := w.Write(respBytes); err != nil { 414 | log.Printf("failed to write response: %v", err) 415 | } 416 | } 417 | 418 | func handleNotFound(w http.ResponseWriter, r *http.Request) { 419 | resp := ErrorResult{false, "not found"} 420 | respBytes, err := json.Marshal(resp) 421 | if err != nil { 422 | log.Printf("failed to encode response: %v", err) 423 | return 424 | } 425 | w.Header().Set("Content-Type", "application/json") 426 | w.WriteHeader(http.StatusNotFound) 427 | if _, err := w.Write(respBytes); err != nil { 428 | log.Printf("failed to write response: %v", err) 429 | } 430 | } 431 | 432 | func handleMethodNotAllowed(w http.ResponseWriter, r *http.Request) { 433 | var endpoint string 434 | var allowedMethods []string 435 | if r.URL.Path == "/upload" { 436 | endpoint = "/upload" 437 | allowedMethods = []string{http.MethodPost} 438 | } 439 | if strings.HasPrefix(r.URL.Path, "/files") { 440 | endpoint = "/files" 441 | allowedMethods = []string{http.MethodGet, http.MethodPut} 442 | } 443 | w.Header().Set("Allow", strings.Join(allowedMethods, ", ")) 444 | resp := ErrorResult{false, fmt.Sprintf("%s is not allowed on %s", r.Method, endpoint)} 445 | respBytes, err := json.Marshal(resp) 446 | if err != nil { 447 | log.Printf("failed to encode response: %v", err) 448 | return 449 | } 450 | w.Header().Set("Content-Type", "application/json") 451 | w.WriteHeader(http.StatusMethodNotAllowed) 452 | if _, err := w.Write(respBytes); err != nil { 453 | log.Printf("failed to write response: %v", err) 454 | } 455 | } 456 | 457 | func getFileSize(r io.Seeker) (int64, error) { 458 | cur, err := r.Seek(0, io.SeekCurrent) 459 | if err != nil { 460 | return 0, err 461 | } 462 | size, err := r.Seek(0, io.SeekEnd) 463 | if err != nil { 464 | return 0, err 465 | } 466 | if _, err := r.Seek(cur, io.SeekStart); err != nil { 467 | return 0, err 468 | } 469 | return size, nil 470 | } 471 | 472 | func parseBoolishValue(s string) bool { 473 | truthyValues := []string{"yes", "true", "1"} 474 | return slices.Contains(truthyValues, strings.ToLower(s)) 475 | } 476 | -------------------------------------------------------------------------------- /pkg/server_it_test.go: -------------------------------------------------------------------------------- 1 | package simpleuploadserver 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "mime/multipart" 10 | "net" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "path" 15 | "path/filepath" 16 | "reflect" 17 | "slices" 18 | "strconv" 19 | "strings" 20 | "testing" 21 | 22 | "github.com/spf13/afero" 23 | ) 24 | 25 | func TestServer(t *testing.T) { 26 | ctx, cancel := context.WithCancel(context.Background()) 27 | defer cancel() 28 | 29 | var docRoot string 30 | var fs afero.Fs 31 | if v, ok := os.LookupEnv("TEST_WITH_REAL_FS"); ok && v != "" { 32 | docRoot = v 33 | fs = afero.NewOsFs() 34 | } else { 35 | docRoot = "/opt/app" 36 | fs = afero.NewMemMapFs() 37 | } 38 | 39 | if err := fs.MkdirAll(docRoot, 0755); err != nil { 40 | t.Fatalf("failed to create document root: %v", err) 41 | } 42 | if err := afero.WriteFile(fs, path.Join(docRoot, "test.txt"), []byte("lorem ipsum"), 0644); err != nil { 43 | t.Fatalf("failed to create test file: %v", err) 44 | } 45 | if err := fs.Mkdir(path.Join(docRoot, "foo"), 0755); err != nil && !os.IsExist(err) { 46 | t.Fatalf("failed to create directory: %v", err) 47 | } 48 | if err := afero.WriteFile(fs, path.Join(docRoot, "foo", "bar.txt"), []byte("hello, world"), 0644); err != nil { 49 | t.Fatalf("failed to create test file: %v", err) 50 | } 51 | 52 | var target string 53 | if addr, ok := os.LookupEnv("TEST_TARGET_ADDR"); ok && addr != "" { 54 | target = addr 55 | } else { 56 | port, err := getAvailablePort() 57 | if err != nil { 58 | t.Fatalf("unable to find an available port: %v", err) 59 | } 60 | target = net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) 61 | config := ServerConfig{ 62 | Addr: target, 63 | DocumentRoot: docRoot, 64 | EnableCORS: true, 65 | MaxUploadSize: 16, 66 | ShutdownTimeout: 5000, 67 | } 68 | ready := make(chan struct{}) 69 | server := Server{config, afero.NewBasePathFs(fs, docRoot)} 70 | go func() { 71 | t.Logf("starting server at %s", target) 72 | server.Start(ctx, ready) // nolint:errcheck 73 | }() 74 | <-ready 75 | } 76 | 77 | base, err := url.Parse("http://" + target) 78 | if err != nil { 79 | t.Fatalf("failed to parse base url: %v", err) 80 | } 81 | 82 | withPreservingOriginal := func(t *testing.T, path string, f func()) { 83 | original, err := afero.ReadFile(fs, path) 84 | if err != nil { 85 | t.Fatalf("failed to read local file: %v", err) 86 | } 87 | f() 88 | if err := afero.WriteFile(fs, path, original, 0644); err != nil { 89 | t.Fatalf("failed to restore original content: %v", err) 90 | } 91 | } 92 | 93 | t.Run("POST /upload", func(t *testing.T) { 94 | u := base.JoinPath("/upload") 95 | req, err := makeFormRequest(u, http.MethodPost, "hello.txt", bytes.NewBufferString("hello, world")) 96 | if err != nil { 97 | t.Fatal(err) 98 | } 99 | resp, err := http.DefaultClient.Do(req) 100 | if err != nil { 101 | t.Fatalf("failed to POST: %v", err) 102 | } 103 | defer resp.Body.Close() 104 | if resp.StatusCode != http.StatusCreated { 105 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusCreated) 106 | } 107 | if ct := resp.Header.Get("Content-Type"); ct != "application/json" { 108 | t.Errorf("Content-Type = %s, want = \"application/json\"", ct) 109 | } 110 | body, err := io.ReadAll(resp.Body) 111 | if err != nil { 112 | t.Fatalf("failed to read response body: %v", err) 113 | } 114 | var result SuccessfullyUploadedResult 115 | if err := json.Unmarshal(body, &result); err != nil { 116 | t.Fatalf("failed to decode response body: %v", err) 117 | } 118 | expected := SuccessfullyUploadedResult{true, "/files/hello.txt"} 119 | if !reflect.DeepEqual(result, expected) { 120 | t.Errorf("result = %+v, want = %+v", result, expected) 121 | } 122 | verifyLocalFile(t, fs, filepath.Join(docRoot, "hello.txt"), []byte("hello, world")) 123 | }) 124 | 125 | t.Run("POST /files/foo.txt should fail due to an invalid method", func(t *testing.T) { 126 | u := base.JoinPath("/files/foo.txt") 127 | req, err := makeFormRequest(u, http.MethodPost, "foo.txt", bytes.NewBufferString("hello, world")) 128 | if err != nil { 129 | t.Fatal(err) 130 | } 131 | resp, err := http.DefaultClient.Do(req) 132 | if err != nil { 133 | t.Fatalf("failed to POST: %v", err) 134 | } 135 | defer resp.Body.Close() 136 | if resp.StatusCode != http.StatusMethodNotAllowed { 137 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusMethodNotAllowed) 138 | } 139 | if resp.Header.Get("Content-Type") != "application/json" { 140 | t.Errorf("Content-Type = %s, want = \"application/json\"", resp.Header.Get("Content-Type")) 141 | } 142 | body, err := io.ReadAll(resp.Body) 143 | if err != nil { 144 | t.Fatalf("failed to read response body: %v", err) 145 | } 146 | var result ErrorResult 147 | if err := json.Unmarshal(body, &result); err != nil { 148 | t.Fatalf("failed to decode response body: %v", err) 149 | } 150 | expected := ErrorResult{false, "POST is not allowed on /files"} 151 | if !reflect.DeepEqual(result, expected) { 152 | t.Errorf("result = %+v, want = %+v", result, expected) 153 | } 154 | }) 155 | 156 | t.Run("POST /upload should fail due to duplication", func(t *testing.T) { 157 | localPath := filepath.Join(docRoot, "test.txt") 158 | localOriginal, err := afero.ReadFile(fs, localPath) 159 | if err != nil { 160 | t.Fatalf("failed to read local file: %v", err) 161 | } 162 | 163 | u := base.JoinPath("/upload") 164 | req, err := makeFormRequest(u, http.MethodPost, "test.txt", bytes.NewBufferString("hello, new world")) 165 | if err != nil { 166 | t.Fatal(err) 167 | } 168 | resp, err := http.DefaultClient.Do(req) 169 | if err != nil { 170 | t.Fatalf("failed to POST: %v", err) 171 | } 172 | defer resp.Body.Close() 173 | if resp.StatusCode != http.StatusConflict { 174 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusConflict) 175 | } 176 | if resp.Header.Get("Content-Type") != "application/json" { 177 | t.Errorf("Content-Type = %s, want = \"application/json\"", resp.Header.Get("Content-Type")) 178 | } 179 | body, err := io.ReadAll(resp.Body) 180 | if err != nil { 181 | t.Fatalf("failed to read response body: %v", err) 182 | } 183 | var result ErrorResult 184 | if err := json.Unmarshal(body, &result); err != nil { 185 | t.Fatalf("failed to decode response body: %v", err) 186 | } 187 | expected := ErrorResult{false, "the file already exists"} 188 | if !reflect.DeepEqual(result, expected) { 189 | t.Errorf("result = %+v, want = %+v", result, expected) 190 | } 191 | verifyLocalFile(t, fs, localPath, localOriginal) 192 | }) 193 | 194 | t.Run("POST /upload should succeed with overwrite option", func(t *testing.T) { 195 | localPath := filepath.Join(docRoot, "test.txt") 196 | withPreservingOriginal(t, localPath, func() { 197 | u := base.JoinPath("/upload") 198 | q := u.Query() 199 | q.Set("overwrite", "true") 200 | u.RawQuery = q.Encode() 201 | 202 | newContent := "hello, new world" 203 | b := bytes.NewBufferString(newContent) 204 | req, err := makeFormRequest(u, http.MethodPost, "test.txt", b) 205 | if err != nil { 206 | t.Fatal(err) 207 | } 208 | resp, err := http.DefaultClient.Do(req) 209 | if err != nil { 210 | t.Fatalf("failed to POST: %v", err) 211 | } 212 | defer resp.Body.Close() 213 | if resp.StatusCode != http.StatusCreated { 214 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusCreated) 215 | } 216 | if resp.Header.Get("Content-Type") != "application/json" { 217 | t.Errorf("Content-Type = %s, want = \"application/json\"", resp.Header.Get("Content-Type")) 218 | } 219 | body, err := io.ReadAll(resp.Body) 220 | if err != nil { 221 | t.Fatalf("failed to read response body: %v", err) 222 | } 223 | var result SuccessfullyUploadedResult 224 | if err := json.Unmarshal(body, &result); err != nil { 225 | t.Fatalf("failed to decode response body: %v", err) 226 | } 227 | expected := SuccessfullyUploadedResult{true, "/files/test.txt"} 228 | if !reflect.DeepEqual(result, expected) { 229 | t.Errorf("result = %+v, want = %+v", result, expected) 230 | } 231 | verifyLocalFile(t, fs, localPath, []byte(newContent)) 232 | }) 233 | }) 234 | 235 | t.Run("PUT /files/hello_put.txt", func(t *testing.T) { 236 | u := base.JoinPath("/files/hello_put.txt") 237 | content := bytes.NewBufferString("hello, world") 238 | req, err := makeFormRequest(u, http.MethodPut, "hello.txt", content) 239 | if err != nil { 240 | t.Fatal(err) 241 | } 242 | resp, err := http.DefaultClient.Do(req) 243 | if err != nil { 244 | t.Fatalf("failed to PUT: %v", err) 245 | } 246 | defer resp.Body.Close() 247 | if resp.StatusCode != http.StatusCreated { 248 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusCreated) 249 | } 250 | if resp.Header.Get("Content-Type") != "application/json" { 251 | t.Errorf("Content-Type = %s, want = \"application/json\"", resp.Header.Get("Content-Type")) 252 | } 253 | body, err := io.ReadAll(resp.Body) 254 | if err != nil { 255 | t.Fatalf("failed to read response body: %v", err) 256 | } 257 | var result SuccessfullyUploadedResult 258 | if err := json.Unmarshal(body, &result); err != nil { 259 | t.Fatalf("failed to decode response body: %v", err) 260 | } 261 | expected := SuccessfullyUploadedResult{true, "/files/hello_put.txt"} 262 | if !reflect.DeepEqual(result, expected) { 263 | t.Errorf("result = %+v, want = %+v", result, expected) 264 | } 265 | verifyLocalFile(t, fs, filepath.Join(docRoot, "hello_put.txt"), []byte("hello, world")) 266 | }) 267 | 268 | t.Run("PUT /files/hello/world.txt", func(t *testing.T) { 269 | u := base.JoinPath("/files/hello/world.txt") 270 | req, err := makeFormRequest(u, http.MethodPut, "world.txt", bytes.NewBufferString("hello, world")) 271 | if err != nil { 272 | t.Fatal(err) 273 | } 274 | resp, err := http.DefaultClient.Do(req) 275 | if err != nil { 276 | t.Fatalf("failed to PUT: %v", err) 277 | } 278 | defer resp.Body.Close() 279 | if resp.StatusCode != http.StatusCreated { 280 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusCreated) 281 | } 282 | if resp.Header.Get("Content-Type") != "application/json" { 283 | t.Errorf("Content-Type = %s, want = \"application/json\"", resp.Header.Get("Content-Type")) 284 | } 285 | body, err := io.ReadAll(resp.Body) 286 | if err != nil { 287 | t.Fatalf("failed to read response body: %v", err) 288 | } 289 | var result SuccessfullyUploadedResult 290 | if err := json.Unmarshal(body, &result); err != nil { 291 | t.Fatalf("failed to decode response body: %v", err) 292 | } 293 | if !result.OK { 294 | t.Errorf("result.OK = false, want = true") 295 | } 296 | if result.Path != "/files/hello/world.txt" { 297 | t.Errorf("path = %s, want = \"/files/hello/world.txt\"", result.Path) 298 | } 299 | if exists, err := afero.DirExists(fs, filepath.Join(docRoot, "hello")); err != nil { 300 | t.Fatalf("failed to check if directory exists: %v", err) 301 | } else if !exists { 302 | t.Errorf("directory /hello does not exist") 303 | } 304 | verifyLocalFile(t, fs, filepath.Join(docRoot, "hello", "world.txt"), []byte("hello, world")) 305 | }) 306 | 307 | t.Run("PUT /upload should fail", func(t *testing.T) { 308 | u := base.JoinPath("/upload") 309 | req, err := makeFormRequest(u, http.MethodPut, "foo.txt", bytes.NewBufferString("hello, world")) 310 | if err != nil { 311 | t.Fatal(err) 312 | } 313 | resp, err := http.DefaultClient.Do(req) 314 | if err != nil { 315 | t.Fatalf("failed to PUT: %v", err) 316 | } 317 | defer resp.Body.Close() 318 | if resp.StatusCode != http.StatusMethodNotAllowed { 319 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusMethodNotAllowed) 320 | } 321 | if resp.Header.Get("Content-Type") != "application/json" { 322 | t.Errorf("Content-Type = %s, want = \"application/json\"", resp.Header.Get("Content-Type")) 323 | } 324 | body, err := io.ReadAll(resp.Body) 325 | if err != nil { 326 | t.Fatalf("failed to read response body: %v", err) 327 | } 328 | var result ErrorResult 329 | if err := json.Unmarshal(body, &result); err != nil { 330 | t.Fatalf("failed to decode response body: %v", err) 331 | } 332 | expected := ErrorResult{false, "PUT is not allowed on /upload"} 333 | if !reflect.DeepEqual(result, expected) { 334 | t.Errorf("result = %+v, want = %+v", result, expected) 335 | } 336 | }) 337 | 338 | t.Run("PUT /files/foo/bar.txt should fail (conflict)", func(t *testing.T) { 339 | u := base.JoinPath("/files/foo/bar.txt") 340 | req, err := makeFormRequest(u, http.MethodPut, "foo.txt", bytes.NewBufferString("new world")) 341 | if err != nil { 342 | t.Fatal(err) 343 | } 344 | resp, err := http.DefaultClient.Do(req) 345 | if err != nil { 346 | t.Fatalf("failed to PUT: %v", err) 347 | } 348 | defer resp.Body.Close() 349 | if resp.StatusCode != http.StatusConflict { 350 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusConflict) 351 | } 352 | if resp.Header.Get("Content-Type") != "application/json" { 353 | t.Errorf("Content-Type = %s, want = \"application/json\"", resp.Header.Get("Content-Type")) 354 | } 355 | body, err := io.ReadAll(resp.Body) 356 | if err != nil { 357 | t.Fatalf("failed to read response body: %v", err) 358 | } 359 | var result ErrorResult 360 | if err := json.Unmarshal(body, &result); err != nil { 361 | t.Fatalf("failed to decode response body: %v", err) 362 | } 363 | expected := ErrorResult{false, "the file already exists"} 364 | if !reflect.DeepEqual(result, expected) { 365 | t.Errorf("result = %+v, want = %+v", result, expected) 366 | } 367 | }) 368 | 369 | t.Run("PUT /files/foo/bar.txt should succeed (overwrite)", func(t *testing.T) { 370 | localPath := filepath.Join(docRoot, "foo", "bar.txt") 371 | withPreservingOriginal(t, localPath, func() { 372 | u := base.JoinPath("/files/foo/bar.txt") 373 | q := u.Query() 374 | q.Set("overwrite", "true") 375 | u.RawQuery = q.Encode() 376 | 377 | req, err := makeFormRequest(u, http.MethodPut, "foo.txt", bytes.NewBufferString("new world")) 378 | if err != nil { 379 | t.Fatal(err) 380 | } 381 | resp, err := http.DefaultClient.Do(req) 382 | if err != nil { 383 | t.Fatalf("failed to PUT: %v", err) 384 | } 385 | defer resp.Body.Close() 386 | if resp.StatusCode != http.StatusCreated { 387 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusCreated) 388 | } 389 | if resp.Header.Get("Content-Type") != "application/json" { 390 | t.Errorf("Content-Type = %s, want = \"application/json\"", resp.Header.Get("Content-Type")) 391 | } 392 | body, err := io.ReadAll(resp.Body) 393 | if err != nil { 394 | t.Fatalf("failed to read response body: %v", err) 395 | } 396 | var result SuccessfullyUploadedResult 397 | if err := json.Unmarshal(body, &result); err != nil { 398 | t.Fatalf("failed to decode response body: %v", err) 399 | } 400 | expected := SuccessfullyUploadedResult{true, "/files/foo/bar.txt"} 401 | if !reflect.DeepEqual(result, expected) { 402 | t.Errorf("result = %+v, want = %+v", result, expected) 403 | } 404 | verifyLocalFile(t, fs, filepath.Join(docRoot, "foo", "bar.txt"), []byte("new world")) 405 | }) 406 | }) 407 | 408 | t.Run("GET /files/foo/bar.txt", func(t *testing.T) { 409 | u := base.JoinPath("/files/foo/bar.txt") 410 | resp, err := http.Get(u.String()) 411 | if err != nil { 412 | t.Fatalf("failed to GET: %v", err) 413 | } 414 | defer resp.Body.Close() 415 | if resp.StatusCode != http.StatusOK { 416 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusOK) 417 | } 418 | body, err := io.ReadAll(resp.Body) 419 | if err != nil { 420 | t.Fatalf("failed to read response body: %v", err) 421 | } 422 | if string(body) != "hello, world" { 423 | t.Errorf("body = %s, want = \"hello, world\"", body) 424 | } 425 | }) 426 | 427 | t.Run("GET /files/foo/bar/baz.txt should fail (not found)", func(t *testing.T) { 428 | u := base.JoinPath("/files/foo/bar/baz.txt") 429 | resp, err := http.Get(u.String()) 430 | if err != nil { 431 | t.Fatalf("failed to GET: %v", err) 432 | } 433 | defer resp.Body.Close() 434 | if resp.StatusCode != http.StatusNotFound { 435 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusNotFound) 436 | } 437 | if resp.Header.Get("Content-Type") != "application/json" { 438 | t.Errorf("Content-Type = %s, want = \"application/json\"", resp.Header.Get("Content-Type")) 439 | } 440 | body, err := io.ReadAll(resp.Body) 441 | if err != nil { 442 | t.Fatalf("failed to read response body: %v", err) 443 | } 444 | var result ErrorResult 445 | if err := json.Unmarshal(body, &result); err != nil { 446 | t.Fatalf("failed to decode response body: %v", err) 447 | } 448 | expected := ErrorResult{false, "file not found"} 449 | if !reflect.DeepEqual(result, expected) { 450 | t.Errorf("result = %+v, want = %+v", result, expected) 451 | } 452 | }) 453 | 454 | t.Run("HEAD /files/foo/bar.txt", func(t *testing.T) { 455 | localPath := filepath.Join(docRoot, "foo", "bar.txt") 456 | 457 | u := base.JoinPath("/files/foo/bar.txt") 458 | resp, err := http.Head(u.String()) 459 | if err != nil { 460 | t.Fatalf("failed to HEAD: %v", err) 461 | } 462 | defer resp.Body.Close() 463 | if resp.StatusCode != http.StatusOK { 464 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusOK) 465 | } 466 | info, err := fs.Stat(localPath) 467 | if err != nil { 468 | t.Fatalf("failed to stat local file: %v", err) 469 | } 470 | cl, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64) 471 | if err != nil { 472 | t.Fatalf("failed to parse Content-Length: %v", err) 473 | } 474 | if cl != info.Size() { 475 | t.Errorf("Content-Length = %d, want = %d", cl, info.Size()) 476 | } 477 | }) 478 | 479 | t.Run("HEAD /files/foo/bar/baz.txt", func(t *testing.T) { 480 | u := base.JoinPath("/files/foo/bar/baz.txt") 481 | resp, err := http.Head(u.String()) 482 | if err != nil { 483 | t.Fatalf("failed to HEAD: %v", err) 484 | } 485 | defer resp.Body.Close() 486 | if resp.StatusCode != http.StatusNotFound { 487 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusNotFound) 488 | } 489 | body, err := io.ReadAll(resp.Body) 490 | if err != nil { 491 | t.Fatalf("failed to read response body: %v", err) 492 | } 493 | if len(body) > 0 { 494 | t.Errorf("body = %s, want empty", body) 495 | } 496 | }) 497 | 498 | t.Run("OPTIONS /files/foo/bar.txt", func(t *testing.T) { 499 | u := base.JoinPath("/files/foo/bar.txt") 500 | req, err := http.NewRequest(http.MethodOptions, u.String(), nil) 501 | if err != nil { 502 | t.Fatalf("failed to create OPTIONS request: %v", err) 503 | } 504 | resp, err := http.DefaultClient.Do(req) 505 | if err != nil { 506 | t.Fatalf("failed to request: %v", err) 507 | } 508 | defer resp.Body.Close() 509 | if resp.StatusCode != http.StatusNoContent { 510 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusNoContent) 511 | } 512 | acam := resp.Header.Get("Access-Control-Allow-Methods") 513 | if acam == "" { 514 | t.Errorf("Access-Control-Allow-Methods got empty, want not empty") 515 | } 516 | allowedMethods := strings.Split(acam, ",") 517 | for i := range allowedMethods { 518 | allowedMethods[i] = strings.TrimSpace(allowedMethods[i]) 519 | } 520 | expectedAllowedMethods := []string{"GET", "HEAD", "PUT"} 521 | if !containsAll(allowedMethods, expectedAllowedMethods) { 522 | t.Errorf("Access-Control-Allow-Methods = %v, want = %v", allowedMethods, expectedAllowedMethods) 523 | } 524 | if acao := resp.Header.Get("Access-Control-Allow-Origin"); acao != "*" { 525 | t.Errorf("Access-Control-Allow-Origin = %s, want = \"*\"", acao) 526 | } 527 | }) 528 | 529 | t.Run("OPTIONS /upload", func(t *testing.T) { 530 | u := base.JoinPath("/upload") 531 | req, err := http.NewRequest(http.MethodOptions, u.String(), nil) 532 | if err != nil { 533 | t.Fatalf("failed to create OPTIONS request: %v", err) 534 | } 535 | resp, err := http.DefaultClient.Do(req) 536 | if err != nil { 537 | t.Fatalf("failed to request: %v", err) 538 | } 539 | defer resp.Body.Close() 540 | if resp.StatusCode != http.StatusNoContent { 541 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusNoContent) 542 | } 543 | acam := resp.Header.Get("Access-Control-Allow-Methods") 544 | if acam == "" { 545 | t.Errorf("Access-Control-Allow-Methods got empty, want not empty") 546 | } 547 | allowedMethods := strings.Split(acam, ",") 548 | for i := range allowedMethods { 549 | allowedMethods[i] = strings.TrimSpace(allowedMethods[i]) 550 | } 551 | expectedAllowedMethods := []string{"POST"} 552 | if !containsAll(allowedMethods, expectedAllowedMethods) { 553 | t.Errorf("Access-Control-Allow-Methods = %v, want = %v", allowedMethods, expectedAllowedMethods) 554 | } 555 | if acao := resp.Header.Get("Access-Control-Allow-Origin"); acao != "*" { 556 | t.Errorf("Access-Control-Allow-Origin = %s, want = \"*\"", acao) 557 | } 558 | }) 559 | } 560 | 561 | func TestServerWithAuth(t *testing.T) { 562 | docRoot := "/opt/app" 563 | 564 | fs := afero.NewMemMapFs() 565 | if err := fs.MkdirAll(docRoot, 0755); err != nil { 566 | t.Fatalf("failed to create document root: %v", err) 567 | } 568 | if err := afero.WriteFile(fs, path.Join(docRoot, "test.txt"), []byte("lorem ipsum"), 0644); err != nil { 569 | t.Fatalf("failed to create test file: %v", err) 570 | } 571 | if err := afero.WriteFile(fs, path.Join(docRoot, "foo", "bar.txt"), []byte("hello, world"), 0644); err != nil { 572 | t.Fatalf("failed to create test file: %v", err) 573 | } 574 | ctx, cancel := context.WithCancel(context.Background()) 575 | defer cancel() 576 | port, err := getAvailablePort() 577 | if err != nil { 578 | t.Fatalf("unable to find an available port: %v", err) 579 | } 580 | 581 | roToken := "read-only-token" 582 | rwToken := "read-write-token" 583 | addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) 584 | config := ServerConfig{ 585 | Addr: addr, 586 | DocumentRoot: docRoot, 587 | EnableCORS: true, 588 | MaxUploadSize: 16, 589 | ShutdownTimeout: 5000, 590 | EnableAuth: true, 591 | ReadOnlyTokens: []string{roToken}, 592 | ReadWriteTokens: []string{rwToken}, 593 | } 594 | ready := make(chan struct{}) 595 | server := Server{config, afero.NewBasePathFs(fs, docRoot)} 596 | go func() { 597 | t.Logf("starting server at %s", addr) 598 | server.Start(ctx, ready) // nolint:errcheck 599 | }() 600 | <-ready 601 | 602 | base, err := url.Parse("http://" + addr) 603 | if err != nil { 604 | t.Fatalf("failed to parse base url: %v", err) 605 | } 606 | 607 | t.Run("POST /upload token via header", func(t *testing.T) { 608 | u := base.JoinPath("/upload") 609 | req, err := makeFormRequest(u, http.MethodPost, "hello.txt", bytes.NewBufferString("hello, world")) 610 | if err != nil { 611 | t.Fatal(err) 612 | } 613 | req.Header.Set("Authorization", "Bearer "+rwToken) 614 | resp, err := http.DefaultClient.Do(req) 615 | if err != nil { 616 | t.Fatalf("failed to POST: %v", err) 617 | } 618 | defer resp.Body.Close() 619 | if resp.StatusCode != http.StatusCreated { 620 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusCreated) 621 | } 622 | if ct := resp.Header.Get("Content-Type"); ct != "application/json" { 623 | t.Errorf("Content-Type = %s, want = \"application/json\"", ct) 624 | } 625 | body, err := io.ReadAll(resp.Body) 626 | if err != nil { 627 | t.Fatalf("failed to read response body: %v", err) 628 | } 629 | var result SuccessfullyUploadedResult 630 | if err := json.Unmarshal(body, &result); err != nil { 631 | t.Fatalf("failed to decode response body: %v", err) 632 | } 633 | expected := SuccessfullyUploadedResult{true, "/files/hello.txt"} 634 | if !reflect.DeepEqual(result, expected) { 635 | t.Errorf("result = %+v, want = %+v", result, expected) 636 | } 637 | verifyLocalFile(t, fs, filepath.Join(docRoot, "hello.txt"), []byte("hello, world")) 638 | }) 639 | 640 | t.Run("POST /upload token via query", func(t *testing.T) { 641 | u := base.JoinPath("/upload") 642 | q := u.Query() 643 | q.Set("token", rwToken) 644 | u.RawQuery = q.Encode() 645 | 646 | req, err := makeFormRequest(u, http.MethodPost, "hello_query.txt", bytes.NewBufferString("hello, world")) 647 | if err != nil { 648 | t.Fatal(err) 649 | } 650 | resp, err := http.DefaultClient.Do(req) 651 | if err != nil { 652 | t.Fatalf("failed to POST: %v", err) 653 | } 654 | defer resp.Body.Close() 655 | if resp.StatusCode != http.StatusCreated { 656 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusCreated) 657 | } 658 | if ct := resp.Header.Get("Content-Type"); ct != "application/json" { 659 | t.Errorf("Content-Type = %s, want = \"application/json\"", ct) 660 | } 661 | body, err := io.ReadAll(resp.Body) 662 | if err != nil { 663 | t.Fatalf("failed to read response body: %v", err) 664 | } 665 | var result SuccessfullyUploadedResult 666 | if err := json.Unmarshal(body, &result); err != nil { 667 | t.Fatalf("failed to decode response body: %v", err) 668 | } 669 | expected := SuccessfullyUploadedResult{true, "/files/hello_query.txt"} 670 | if !reflect.DeepEqual(result, expected) { 671 | t.Errorf("result = %+v, want = %+v", result, expected) 672 | } 673 | verifyLocalFile(t, fs, filepath.Join(docRoot, "hello_query.txt"), []byte("hello, world")) 674 | }) 675 | 676 | t.Run("POST /upload with read-only token", func(t *testing.T) { 677 | u := base.JoinPath("/upload") 678 | req, err := makeFormRequest(u, http.MethodPost, "hello.txt", bytes.NewBufferString("hello, world")) 679 | if err != nil { 680 | t.Fatal(err) 681 | } 682 | req.Header.Set("Authorization", "Bearer "+roToken) 683 | resp, err := http.DefaultClient.Do(req) 684 | if err != nil { 685 | t.Fatalf("failed to POST: %v", err) 686 | } 687 | defer resp.Body.Close() 688 | if resp.StatusCode != http.StatusUnauthorized { 689 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusUnauthorized) 690 | } 691 | if ct := resp.Header.Get("Content-Type"); ct != "application/json" { 692 | t.Errorf("Content-Type = %s, want = \"application/json\"", ct) 693 | } 694 | body, err := io.ReadAll(resp.Body) 695 | if err != nil { 696 | t.Fatalf("failed to read response body: %v", err) 697 | } 698 | var result ErrorResult 699 | if err := json.Unmarshal(body, &result); err != nil { 700 | t.Fatalf("failed to decode response body: %v", err) 701 | } 702 | expected := ErrorResult{false, "unauthorized"} 703 | if !reflect.DeepEqual(result, expected) { 704 | t.Errorf("result = %+v, want = %+v", result, expected) 705 | } 706 | }) 707 | 708 | t.Run("POST /upload without token", func(t *testing.T) { 709 | u := base.JoinPath("/upload") 710 | req, err := makeFormRequest(u, http.MethodPost, "hello.txt", bytes.NewBufferString("hello, world")) 711 | if err != nil { 712 | t.Fatal(err) 713 | } 714 | resp, err := http.DefaultClient.Do(req) 715 | if err != nil { 716 | t.Fatalf("failed to POST: %v", err) 717 | } 718 | defer resp.Body.Close() 719 | if resp.StatusCode != http.StatusUnauthorized { 720 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusUnauthorized) 721 | } 722 | if ct := resp.Header.Get("Content-Type"); ct != "application/json" { 723 | t.Errorf("Content-Type = %s, want = \"application/json\"", ct) 724 | } 725 | body, err := io.ReadAll(resp.Body) 726 | if err != nil { 727 | t.Fatalf("failed to read response body: %v", err) 728 | } 729 | var result ErrorResult 730 | if err := json.Unmarshal(body, &result); err != nil { 731 | t.Fatalf("failed to decode response body: %v", err) 732 | } 733 | expected := ErrorResult{false, "unauthorized"} 734 | if !reflect.DeepEqual(result, expected) { 735 | t.Errorf("result = %+v, want = %+v", result, expected) 736 | } 737 | }) 738 | 739 | t.Run("PUT /files/hello_put.txt with read-write token", func(t *testing.T) { 740 | u := base.JoinPath("/files/hello_put.txt") 741 | content := bytes.NewBufferString("hello, world") 742 | req, err := makeFormRequest(u, http.MethodPut, "hello.txt", content) 743 | if err != nil { 744 | t.Fatal(err) 745 | } 746 | req.Header.Set("Authorization", "Bearer "+rwToken) 747 | resp, err := http.DefaultClient.Do(req) 748 | if err != nil { 749 | t.Fatalf("failed to PUT: %v", err) 750 | } 751 | defer resp.Body.Close() 752 | if resp.StatusCode != http.StatusCreated { 753 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusCreated) 754 | } 755 | if resp.Header.Get("Content-Type") != "application/json" { 756 | t.Errorf("Content-Type = %s, want = \"application/json\"", resp.Header.Get("Content-Type")) 757 | } 758 | body, err := io.ReadAll(resp.Body) 759 | if err != nil { 760 | t.Fatalf("failed to read response body: %v", err) 761 | } 762 | var result SuccessfullyUploadedResult 763 | if err := json.Unmarshal(body, &result); err != nil { 764 | t.Fatalf("failed to decode response body: %v", err) 765 | } 766 | expected := SuccessfullyUploadedResult{true, "/files/hello_put.txt"} 767 | if !reflect.DeepEqual(result, expected) { 768 | t.Errorf("result = %+v, want = %+v", result, expected) 769 | } 770 | verifyLocalFile(t, fs, filepath.Join(docRoot, "hello_put.txt"), []byte("hello, world")) 771 | }) 772 | 773 | t.Run("PUT /files/hello_put.txt with read-only token", func(t *testing.T) { 774 | u := base.JoinPath("/files/hello_put.txt") 775 | content := bytes.NewBufferString("hello, world") 776 | req, err := makeFormRequest(u, http.MethodPut, "hello.txt", content) 777 | if err != nil { 778 | t.Fatal(err) 779 | } 780 | req.Header.Set("Authorization", "Bearer "+roToken) 781 | resp, err := http.DefaultClient.Do(req) 782 | if err != nil { 783 | t.Fatalf("failed to PUT: %v", err) 784 | } 785 | defer resp.Body.Close() 786 | if resp.StatusCode != http.StatusUnauthorized { 787 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusUnauthorized) 788 | } 789 | if resp.Header.Get("Content-Type") != "application/json" { 790 | t.Errorf("Content-Type = %s, want = \"application/json\"", resp.Header.Get("Content-Type")) 791 | } 792 | body, err := io.ReadAll(resp.Body) 793 | if err != nil { 794 | t.Fatalf("failed to read response body: %v", err) 795 | } 796 | var result ErrorResult 797 | if err := json.Unmarshal(body, &result); err != nil { 798 | t.Fatalf("failed to decode response body: %v", err) 799 | } 800 | expected := ErrorResult{false, "unauthorized"} 801 | if !reflect.DeepEqual(result, expected) { 802 | t.Errorf("result = %+v, want = %+v", result, expected) 803 | } 804 | }) 805 | 806 | t.Run("PUT /files/hello_put.txt without tokens", func(t *testing.T) { 807 | u := base.JoinPath("/files/hello_put.txt") 808 | content := bytes.NewBufferString("hello, world") 809 | req, err := makeFormRequest(u, http.MethodPut, "hello.txt", content) 810 | if err != nil { 811 | t.Fatal(err) 812 | } 813 | resp, err := http.DefaultClient.Do(req) 814 | if err != nil { 815 | t.Fatalf("failed to PUT: %v", err) 816 | } 817 | defer resp.Body.Close() 818 | if resp.StatusCode != http.StatusUnauthorized { 819 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusUnauthorized) 820 | } 821 | if resp.Header.Get("Content-Type") != "application/json" { 822 | t.Errorf("Content-Type = %s, want = \"application/json\"", resp.Header.Get("Content-Type")) 823 | } 824 | body, err := io.ReadAll(resp.Body) 825 | if err != nil { 826 | t.Fatalf("failed to read response body: %v", err) 827 | } 828 | var result ErrorResult 829 | if err := json.Unmarshal(body, &result); err != nil { 830 | t.Fatalf("failed to decode response body: %v", err) 831 | } 832 | expected := ErrorResult{false, "unauthorized"} 833 | if !reflect.DeepEqual(result, expected) { 834 | t.Errorf("result = %+v, want = %+v", result, expected) 835 | } 836 | }) 837 | 838 | t.Run("GET /files/foo/bar.txt using rw token with Authorization header", func(t *testing.T) { 839 | u := base.JoinPath("/files/foo/bar.txt") 840 | req, err := http.NewRequest(http.MethodGet, u.String(), nil) 841 | if err != nil { 842 | t.Fatalf("failed to create GET request: %v", err) 843 | } 844 | req.Header.Set("Authorization", "Bearer "+rwToken) 845 | resp, err := http.DefaultClient.Do(req) 846 | if err != nil { 847 | t.Fatalf("failed to GET: %v", err) 848 | } 849 | defer resp.Body.Close() 850 | if resp.StatusCode != http.StatusOK { 851 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusOK) 852 | } 853 | body, err := io.ReadAll(resp.Body) 854 | if err != nil { 855 | t.Fatalf("failed to read response body: %v", err) 856 | } 857 | if string(body) != "hello, world" { 858 | t.Errorf("body = %s, want = \"hello, world\"", body) 859 | } 860 | }) 861 | 862 | t.Run("GET /files/foo/bar.txt using ro token with Authorization header", func(t *testing.T) { 863 | u := base.JoinPath("/files/foo/bar.txt") 864 | req, err := http.NewRequest(http.MethodGet, u.String(), nil) 865 | if err != nil { 866 | t.Fatalf("failed to create GET request: %v", err) 867 | } 868 | req.Header.Set("Authorization", "Bearer "+roToken) 869 | resp, err := http.DefaultClient.Do(req) 870 | if err != nil { 871 | t.Fatalf("failed to GET: %v", err) 872 | } 873 | defer resp.Body.Close() 874 | if resp.StatusCode != http.StatusOK { 875 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusOK) 876 | } 877 | body, err := io.ReadAll(resp.Body) 878 | if err != nil { 879 | t.Fatalf("failed to read response body: %v", err) 880 | } 881 | if string(body) != "hello, world" { 882 | t.Errorf("body = %s, want = \"hello, world\"", body) 883 | } 884 | }) 885 | 886 | t.Run("GET /files/foo/bar.txt using ro token with query parameter", func(t *testing.T) { 887 | u := base.JoinPath("/files/foo/bar.txt") 888 | q := u.Query() 889 | q.Set("token", roToken) 890 | u.RawQuery = q.Encode() 891 | 892 | req, err := http.NewRequest(http.MethodGet, u.String(), nil) 893 | if err != nil { 894 | t.Fatalf("failed to create GET request: %v", err) 895 | } 896 | resp, err := http.DefaultClient.Do(req) 897 | if err != nil { 898 | t.Fatalf("failed to GET: %v", err) 899 | } 900 | defer resp.Body.Close() 901 | if resp.StatusCode != http.StatusOK { 902 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusOK) 903 | } 904 | body, err := io.ReadAll(resp.Body) 905 | if err != nil { 906 | t.Fatalf("failed to read response body: %v", err) 907 | } 908 | if string(body) != "hello, world" { 909 | t.Errorf("body = %s, want = \"hello, world\"", body) 910 | } 911 | }) 912 | 913 | t.Run("GET /files/foo/bar.txt without tokens", func(t *testing.T) { 914 | u := base.JoinPath("/files/foo/bar.txt") 915 | req, err := http.NewRequest(http.MethodGet, u.String(), nil) 916 | if err != nil { 917 | t.Fatalf("failed to create GET request: %v", err) 918 | } 919 | resp, err := http.DefaultClient.Do(req) 920 | if err != nil { 921 | t.Fatalf("failed to GET: %v", err) 922 | } 923 | defer resp.Body.Close() 924 | if resp.StatusCode != http.StatusUnauthorized { 925 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusUnauthorized) 926 | } 927 | body, err := io.ReadAll(resp.Body) 928 | if err != nil { 929 | t.Fatalf("failed to read response body: %v", err) 930 | } 931 | var result ErrorResult 932 | if err := json.Unmarshal(body, &result); err != nil { 933 | t.Fatalf("failed to decode response body: %v", err) 934 | } 935 | expected := ErrorResult{false, "unauthorized"} 936 | if !reflect.DeepEqual(result, expected) { 937 | t.Errorf("result = %+v, want = %+v", result, expected) 938 | } 939 | }) 940 | 941 | t.Run("HEAD /files/foo/bar.txt using rw token with Authorization header", func(t *testing.T) { 942 | u := base.JoinPath("/files/foo/bar.txt") 943 | req, err := http.NewRequest(http.MethodHead, u.String(), nil) 944 | if err != nil { 945 | t.Fatalf("failed to create HEAD request: %v", err) 946 | } 947 | req.Header.Set("Authorization", "Bearer "+rwToken) 948 | resp, err := http.DefaultClient.Do(req) 949 | if err != nil { 950 | t.Fatalf("failed to GET: %v", err) 951 | } 952 | defer resp.Body.Close() 953 | if resp.StatusCode != http.StatusOK { 954 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusOK) 955 | } 956 | }) 957 | 958 | t.Run("HEAD /files/foo/bar.txt using ro token with Authorization header", func(t *testing.T) { 959 | u := base.JoinPath("/files/foo/bar.txt") 960 | req, err := http.NewRequest(http.MethodHead, u.String(), nil) 961 | if err != nil { 962 | t.Fatalf("failed to create HEAD request: %v", err) 963 | } 964 | req.Header.Set("Authorization", "Bearer "+roToken) 965 | resp, err := http.DefaultClient.Do(req) 966 | if err != nil { 967 | t.Fatalf("failed to HEAD: %v", err) 968 | } 969 | defer resp.Body.Close() 970 | if resp.StatusCode != http.StatusOK { 971 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusOK) 972 | } 973 | }) 974 | 975 | t.Run("HEAD /files/foo/bar.txt using ro token with query parameter", func(t *testing.T) { 976 | u := base.JoinPath("/files/foo/bar.txt") 977 | q := u.Query() 978 | q.Set("token", roToken) 979 | u.RawQuery = q.Encode() 980 | 981 | req, err := http.NewRequest(http.MethodHead, u.String(), nil) 982 | if err != nil { 983 | t.Fatalf("failed to create HEAD request: %v", err) 984 | } 985 | resp, err := http.DefaultClient.Do(req) 986 | if err != nil { 987 | t.Fatalf("failed to HEAD: %v", err) 988 | } 989 | defer resp.Body.Close() 990 | if resp.StatusCode != http.StatusOK { 991 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusOK) 992 | } 993 | }) 994 | 995 | t.Run("HEAD /files/foo/bar.txt without tokens", func(t *testing.T) { 996 | u := base.JoinPath("/files/foo/bar.txt") 997 | req, err := http.NewRequest(http.MethodHead, u.String(), nil) 998 | if err != nil { 999 | t.Fatalf("failed to create HEAD request: %v", err) 1000 | } 1001 | resp, err := http.DefaultClient.Do(req) 1002 | if err != nil { 1003 | t.Fatalf("failed to HEAD: %v", err) 1004 | } 1005 | defer resp.Body.Close() 1006 | if resp.StatusCode != http.StatusUnauthorized { 1007 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusUnauthorized) 1008 | } 1009 | }) 1010 | 1011 | t.Run("OPTIONS /files/foo/bar.txt using rw token with Authorization header", func(t *testing.T) { 1012 | u := base.JoinPath("/files/foo/bar.txt") 1013 | req, err := http.NewRequest(http.MethodOptions, u.String(), nil) 1014 | if err != nil { 1015 | t.Fatalf("failed to create OPTIONS request: %v", err) 1016 | } 1017 | req.Header.Set("Authorization", "Bearer "+rwToken) 1018 | resp, err := http.DefaultClient.Do(req) 1019 | if err != nil { 1020 | t.Fatalf("failed to OPTIONS: %v", err) 1021 | } 1022 | defer resp.Body.Close() 1023 | if resp.StatusCode != http.StatusNoContent { 1024 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusNoContent) 1025 | } 1026 | acam := resp.Header.Get("Access-Control-Allow-Methods") 1027 | if acam == "" { 1028 | t.Errorf("Access-Control-Allow-Methods got empty, want not empty") 1029 | } 1030 | allowedMethods := strings.Split(acam, ",") 1031 | for i := range allowedMethods { 1032 | allowedMethods[i] = strings.TrimSpace(allowedMethods[i]) 1033 | } 1034 | expectedAllowedMethods := []string{"GET", "HEAD", "PUT"} 1035 | if !containsAll(allowedMethods, expectedAllowedMethods) { 1036 | t.Errorf("Access-Control-Allow-Methods = %v, want = %v", allowedMethods, expectedAllowedMethods) 1037 | } 1038 | if acao := resp.Header.Get("Access-Control-Allow-Origin"); acao != "*" { 1039 | t.Errorf("Access-Control-Allow-Origin = %s, want = \"*\"", acao) 1040 | } 1041 | }) 1042 | 1043 | // check the authentication does not affect the response of OPTIONS. 1044 | t.Run("OPTIONS /files/foo/bar.txt using ro token with Authorization header", func(t *testing.T) { 1045 | u := base.JoinPath("/files/foo/bar.txt") 1046 | req, err := http.NewRequest(http.MethodOptions, u.String(), nil) 1047 | if err != nil { 1048 | t.Fatalf("failed to create OPTIONS request: %v", err) 1049 | } 1050 | req.Header.Set("Authorization", "Bearer "+roToken) 1051 | resp, err := http.DefaultClient.Do(req) 1052 | if err != nil { 1053 | t.Fatalf("failed to OPTIONS: %v", err) 1054 | } 1055 | defer resp.Body.Close() 1056 | if resp.StatusCode != http.StatusNoContent { 1057 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusNoContent) 1058 | } 1059 | acam := resp.Header.Get("Access-Control-Allow-Methods") 1060 | if acam == "" { 1061 | t.Errorf("Access-Control-Allow-Methods got empty, want not empty") 1062 | } 1063 | allowedMethods := strings.Split(acam, ",") 1064 | for i := range allowedMethods { 1065 | allowedMethods[i] = strings.TrimSpace(allowedMethods[i]) 1066 | } 1067 | expectedAllowedMethods := []string{"GET", "HEAD", "PUT"} 1068 | if !containsAll(allowedMethods, expectedAllowedMethods) { 1069 | t.Errorf("Access-Control-Allow-Methods = %v, want = %v", allowedMethods, expectedAllowedMethods) 1070 | } 1071 | if acao := resp.Header.Get("Access-Control-Allow-Origin"); acao != "*" { 1072 | t.Errorf("Access-Control-Allow-Origin = %s, want = \"*\"", acao) 1073 | } 1074 | }) 1075 | 1076 | t.Run("OPTIONS /files/foo/bar.txt using ro token with query parameter", func(t *testing.T) { 1077 | u := base.JoinPath("/files/foo/bar.txt") 1078 | q := u.Query() 1079 | q.Set("token", roToken) 1080 | u.RawQuery = q.Encode() 1081 | 1082 | req, err := http.NewRequest(http.MethodOptions, u.String(), nil) 1083 | if err != nil { 1084 | t.Fatalf("failed to create OPTIONS request: %v", err) 1085 | } 1086 | req.Header.Set("Authorization", "Bearer "+roToken) 1087 | resp, err := http.DefaultClient.Do(req) 1088 | if err != nil { 1089 | t.Fatalf("failed to OPTIONS: %v", err) 1090 | } 1091 | defer resp.Body.Close() 1092 | if resp.StatusCode != http.StatusNoContent { 1093 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusNoContent) 1094 | } 1095 | acam := resp.Header.Get("Access-Control-Allow-Methods") 1096 | if acam == "" { 1097 | t.Errorf("Access-Control-Allow-Methods got empty, want not empty") 1098 | } 1099 | allowedMethods := strings.Split(acam, ",") 1100 | for i := range allowedMethods { 1101 | allowedMethods[i] = strings.TrimSpace(allowedMethods[i]) 1102 | } 1103 | expectedAllowedMethods := []string{"GET", "HEAD", "PUT"} 1104 | if !containsAll(allowedMethods, expectedAllowedMethods) { 1105 | t.Errorf("Access-Control-Allow-Methods = %v, want = %v", allowedMethods, expectedAllowedMethods) 1106 | } 1107 | if acao := resp.Header.Get("Access-Control-Allow-Origin"); acao != "*" { 1108 | t.Errorf("Access-Control-Allow-Origin = %s, want = \"*\"", acao) 1109 | } 1110 | }) 1111 | 1112 | t.Run("OPTIONS /files/foo/bar.txt without tokens", func(t *testing.T) { 1113 | u := base.JoinPath("/files/foo/bar.txt") 1114 | req, err := http.NewRequest(http.MethodOptions, u.String(), nil) 1115 | if err != nil { 1116 | t.Fatalf("failed to create OPTIONS request: %v", err) 1117 | } 1118 | resp, err := http.DefaultClient.Do(req) 1119 | if err != nil { 1120 | t.Fatalf("failed to OPTIONS: %v", err) 1121 | } 1122 | defer resp.Body.Close() 1123 | if resp.StatusCode != http.StatusNoContent { 1124 | t.Errorf("status = %d, want = %d", resp.StatusCode, http.StatusNoContent) 1125 | } 1126 | acam := resp.Header.Get("Access-Control-Allow-Methods") 1127 | if acam == "" { 1128 | t.Errorf("Access-Control-Allow-Methods got empty, want not empty") 1129 | } 1130 | allowedMethods := strings.Split(acam, ",") 1131 | for i := range allowedMethods { 1132 | allowedMethods[i] = strings.TrimSpace(allowedMethods[i]) 1133 | } 1134 | expectedAllowedMethods := []string{"GET", "HEAD", "PUT"} 1135 | if !containsAll(allowedMethods, expectedAllowedMethods) { 1136 | t.Errorf("Access-Control-Allow-Methods = %v, want = %v", allowedMethods, expectedAllowedMethods) 1137 | } 1138 | if acao := resp.Header.Get("Access-Control-Allow-Origin"); acao != "*" { 1139 | t.Errorf("Access-Control-Allow-Origin = %s, want = \"*\"", acao) 1140 | } 1141 | }) 1142 | } 1143 | 1144 | func verifyLocalFile(t *testing.T, fs afero.Fs, path string, content []byte) { 1145 | got, err := afero.ReadFile(fs, path) 1146 | if err != nil { 1147 | t.Fatalf("failed to read local file: %v", err) 1148 | } 1149 | if !reflect.DeepEqual(got, content) { 1150 | t.Errorf("local file content = %s, want = %s", got, content) 1151 | } 1152 | } 1153 | 1154 | // containsAll reports whether a contains all elements of b. 1155 | func containsAll[T comparable](a, b []T) bool { 1156 | for _, x := range b { 1157 | if !slices.Contains(a, x) { 1158 | return false 1159 | } 1160 | } 1161 | return true 1162 | } 1163 | 1164 | // makeFormRequest creates a new http.Request with multipart/form-data. 1165 | func makeFormRequest(url *url.URL, method, filename string, content io.Reader) (*http.Request, error) { 1166 | b := new(bytes.Buffer) 1167 | w := multipart.NewWriter(b) 1168 | fw, err := w.CreateFormFile("file", filename) 1169 | if err != nil { 1170 | return nil, err 1171 | } 1172 | written, err := io.Copy(fw, content) 1173 | if err != nil { 1174 | return nil, err 1175 | } 1176 | if written == 0 { 1177 | return nil, fmt.Errorf("no bytes written") 1178 | } 1179 | w.Close() 1180 | 1181 | req, err := http.NewRequest(method, url.String(), b) 1182 | if err != nil { 1183 | return nil, err 1184 | } 1185 | req.Header.Set("Content-Type", w.FormDataContentType()) 1186 | return req, nil 1187 | } 1188 | 1189 | // getAvailablePort returns an available port number randomly. 1190 | func getAvailablePort() (int, error) { 1191 | addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:0") 1192 | if err != nil { 1193 | return 0, err 1194 | } 1195 | listener, err := net.ListenTCP("tcp", addr) 1196 | if err != nil { 1197 | return 0, err 1198 | } 1199 | defer listener.Close() 1200 | return listener.Addr().(*net.TCPAddr).Port, nil 1201 | } 1202 | --------------------------------------------------------------------------------