├── .gitattributes ├── testdata ├── file2.txt ├── file.txt └── file.txt.gz ├── go.mod ├── .travis.yml ├── .gitignore ├── .github └── workflows │ ├── test.yml │ └── golangci-lint.yml ├── go.sum ├── LICENSE ├── filesystem.go ├── fileserver.go ├── README.md └── fileserver_test.go /.gitattributes: -------------------------------------------------------------------------------- 1 | * -text 2 | -------------------------------------------------------------------------------- /testdata/file2.txt: -------------------------------------------------------------------------------- 1 | 1234567890987654321 2 | -------------------------------------------------------------------------------- /testdata/file.txt: -------------------------------------------------------------------------------- 1 | zyxwvutsrqponmlkjihgfedcba 2 | -------------------------------------------------------------------------------- /testdata/file.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lpar/gzipped/HEAD/testdata/file.txt.gz -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lpar/gzipped/v2 2 | 3 | require github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 4 | 5 | go 1.18 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.x 5 | 6 | before_install: 7 | - go get -t -v ./... 8 | 9 | script: 10 | - go test -race -coverprofile=coverage.txt -covermode=atomic 11 | 12 | after_success: 13 | - bash <(curl -s https://codecov.io/bash) 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | .idea 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.14.x] 8 | platform: [ubuntu-latest, macos-latest, windows-latest] 9 | runs-on: ${{ matrix.platform }} 10 | steps: 11 | - name: Install Go 12 | uses: actions/setup-go@v2 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | - name: Test 18 | run: go test ./... 19 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | branches: 7 | - master 8 | pull_request: 9 | jobs: 10 | golangci: 11 | name: lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: golangci-lint 16 | uses: golangci/golangci-lint-action@v1 17 | with: 18 | # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. 19 | version: v1.28 20 | 21 | # Optional: working directory, useful for monorepos 22 | # working-directory: somedir 23 | 24 | # Optional: golangci-lint command line arguments. 25 | # args: --issues-exit-code=0 26 | 27 | # Optional: show only new issues if it's a pull request. The default value is `false`. 28 | # only-new-issues: true 29 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/kevinpollet/nego v0.0.0-20200324111829-b3061ca9dd9d h1:BaIpmhcqpBnz4+NZjUjVGxKNA+/E7ovKsjmwqjXcGYc= 4 | github.com/kevinpollet/nego v0.0.0-20200324111829-b3061ca9dd9d/go.mod h1:3FSWkzk9h42opyV0o357Fq6gsLF/A6MI/qOca9kKobY= 5 | github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 h1:Pdirg1gwhEcGjMLyuSxGn9664p+P8J9SrfMgpFwrDyg= 6 | github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43/go.mod h1:ahLMuLCUyDdXqtqGyuwGev7/PGtO7r7ocvdwDuEN/3E= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 10 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= 11 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 12 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 14 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 15 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-2020, IBM Corporation. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | * Neither the name of IBM nor the names of project contributors may be used 14 | to endorse or promote products derived from this software without specific 15 | prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 18 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 21 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 22 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 23 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 25 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /filesystem.go: -------------------------------------------------------------------------------- 1 | package gzipped 2 | 3 | import ( 4 | "fmt" 5 | fs2 "io/fs" 6 | "net/http" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "strings" 11 | ) 12 | 13 | // FileSystem is a wrapper around the http.FileSystem interface, adding a method to let us check for the existence 14 | // of files without (attempting to) open them. 15 | type FileSystem interface { 16 | http.FileSystem 17 | Exists(string) bool 18 | } 19 | 20 | // Dir is a replacement for the http.Dir type, and implements FileSystem. 21 | type Dir string 22 | 23 | // Exists tests whether a file with the specified name exists, resolved relative to the base directory. 24 | func (d Dir) Exists(name string) bool { 25 | if filepath.Separator != '/' && strings.ContainsRune(name, filepath.Separator) { 26 | return false 27 | } 28 | dir := string(d) 29 | if dir == "" { 30 | dir = "." 31 | } 32 | fullName := filepath.Join(dir, filepath.FromSlash(path.Clean("/"+name))) 33 | _, err := os.Stat(fullName) 34 | return err == nil 35 | } 36 | 37 | // Open defers to http.Dir's Open so that gzipped.Dir implements http.FileSystem. 38 | func (d Dir) Open(name string) (http.File, error) { 39 | return http.Dir(d).Open(name) 40 | } 41 | 42 | // FS takes a Go fs.FS and returns a FileSystem suitable for use with FileServer. 43 | func FS(f fs2.FS) FileSystem { 44 | return fs{fs: f} 45 | } 46 | 47 | type fs struct { 48 | fs fs2.FS 49 | } 50 | 51 | // Exists tests whether a file with the specified name exists, resolved relative to the file system. 52 | func (f fs) Exists(name string) bool { 53 | if filepath.Separator != '/' && strings.ContainsRune(name, filepath.Separator) { 54 | return false 55 | } 56 | _, err := fs2.Stat(f.fs, strings.TrimPrefix(filepath.FromSlash(path.Clean(name)), "/")) 57 | return err == nil 58 | } 59 | 60 | // Open defers to http.FS's Open so that gzipped.fs implements http.FileSystem. 61 | func (f fs) Open(name string) (http.File, error) { 62 | return http.FS(f.fs).Open(strings.TrimPrefix(name, "/")) 63 | } 64 | -------------------------------------------------------------------------------- /fileserver.go: -------------------------------------------------------------------------------- 1 | package gzipped 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | "path" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/kevinpollet/nego" 12 | ) 13 | 14 | // List of encodings we would prefer to use, in order of preference, best first. 15 | var preferredEncodings = []string{"br", "gzip", "identity"} 16 | 17 | // File extension to use for different encodings. 18 | func extensionForEncoding(encname string) string { 19 | switch encname { 20 | case "gzip": 21 | return ".gz" 22 | case "br": 23 | return ".br" 24 | case "identity": 25 | return "" 26 | } 27 | return "" 28 | } 29 | 30 | // Function to negotiate the best content encoding 31 | // Pulled out here so we have the option of overriding nego's behavior and so we can test 32 | func negotiate(r *http.Request, available []string) string { 33 | return nego.NegotiateContentEncoding(r, available...) 34 | } 35 | 36 | type fileHandler struct { 37 | root FileSystem 38 | } 39 | 40 | // FileServer is a drop-in replacement for Go's standard http.FileServer 41 | // which adds support for static resources precompressed with gzip, at 42 | // the cost of removing the support for directory browsing. 43 | // 44 | // If file filename.ext has a compressed version filename.ext.gz alongside 45 | // it, if the client indicates that it accepts gzip-compressed data, and 46 | // if the .gz file can be opened, then the compressed version of the file 47 | // will be sent to the client. Otherwise the request is passed on to 48 | // http.ServeContent, and the raw (uncompressed) version is used. 49 | // 50 | // It is up to you to ensure that the compressed and uncompressed versions 51 | // of files match and have sensible timestamps. 52 | // 53 | // Compressed or not, requests are fulfilled using http.ServeContent, and 54 | // details like accept ranges and content-type sniffing are handled by that 55 | // method. 56 | func FileServer(root FileSystem) http.Handler { 57 | return &fileHandler{root} 58 | } 59 | 60 | func (f *fileHandler) openAndStat(path string) (http.File, os.FileInfo, error) { 61 | file, err := f.root.Open(path) 62 | var info os.FileInfo 63 | // This slightly weird variable reuse is so we can get 100% test coverage 64 | // without having to come up with a test file that can be opened, yet 65 | // fails to stat. 66 | if err == nil { 67 | info, err = file.Stat() 68 | } 69 | if err != nil { 70 | return file, nil, err 71 | } 72 | if info.IsDir() { 73 | return file, nil, fmt.Errorf("%s is directory", path) 74 | } 75 | return file, info, nil 76 | } 77 | 78 | const ( 79 | acceptEncodingHeader = "Accept-Encoding" 80 | contentEncodingHeader = "Content-Encoding" 81 | contentLengthHeader = "Content-Length" 82 | rangeHeader = "Range" 83 | varyHeader = "Vary" 84 | ) 85 | 86 | // Find the best file to serve based on the client's Accept-Encoding, and which 87 | // files actually exist on the filesystem. If no file was found that can satisfy 88 | // the request, the error field will be non-nil. 89 | func (f *fileHandler) findBestFile(w http.ResponseWriter, r *http.Request, fpath string) (http.File, os.FileInfo, error) { 90 | ae := r.Header.Get(acceptEncodingHeader) 91 | if ae == "" { 92 | return f.openAndStat(fpath) 93 | } 94 | // Got an accept header? See what possible encodings we can send by looking for files 95 | var available []string 96 | for _, posenc := range preferredEncodings { 97 | ext := extensionForEncoding(posenc) 98 | fname := fpath + ext 99 | if f.root.Exists(fname) { 100 | available = append(available, posenc) 101 | } 102 | } 103 | if len(available) == 0 { 104 | return f.openAndStat(fpath) 105 | } 106 | // Carry out standard HTTP negotiation 107 | negenc := negotiate(r, available) 108 | if negenc == "" || negenc == "identity" { 109 | // If we fail to negotiate anything or if we negotiated the identity encoding, again try the base file 110 | return f.openAndStat(fpath) 111 | } 112 | ext := extensionForEncoding(negenc) 113 | if file, info, err := f.openAndStat(fpath + ext); err == nil { 114 | wHeader := w.Header() 115 | wHeader[contentEncodingHeader] = []string{negenc} 116 | wHeader.Add(varyHeader, acceptEncodingHeader) 117 | 118 | if len(r.Header[rangeHeader]) == 0 { 119 | // If not a range request then we can easily set the content length which the 120 | // Go standard library does not do if "Content-Encoding" is set. 121 | wHeader[contentLengthHeader] = []string{strconv.FormatInt(info.Size(), 10)} 122 | } 123 | return file, info, nil 124 | } 125 | 126 | // If all else failed, fall back to base file once again 127 | return f.openAndStat(fpath) 128 | } 129 | 130 | func (f *fileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 131 | upath := r.URL.Path 132 | if !strings.HasPrefix(upath, "/") { 133 | upath = "/" + upath 134 | r.URL.Path = upath 135 | } 136 | fpath := path.Clean(upath) 137 | if strings.HasSuffix(fpath, "/") { 138 | // If you wanted to put back directory browsing support, this is 139 | // where you'd do it. 140 | http.NotFound(w, r) 141 | return 142 | } 143 | 144 | // Find the best acceptable file, including trying uncompressed 145 | if file, info, err := f.findBestFile(w, r, fpath); err == nil { 146 | http.ServeContent(w, r, fpath, info.ModTime(), file) 147 | file.Close() 148 | return 149 | } 150 | 151 | // Doesn't exist, compressed or uncompressed 152 | http.NotFound(w, r) 153 | } 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GoDoc](https://godoc.org/github.com/lpar/gzipped?status.svg)](https://godoc.org/github.com/lpar/gzipped) 2 | [![Build Status](https://travis-ci.org/lpar/gzipped.svg?branch=trunk)](https://travis-ci.org/lpar/gzipped) 3 | [![codecov](https://codecov.io/gh/lpar/gzipped/branch/trunk/graph/badge.svg)](https://codecov.io/gh/lpar/gzipped) 4 | 5 | # gzipped.FileServer 6 | 7 | Drop-in replacement for golang http.FileServer which supports static content 8 | compressed with gzip (including zopfli) or brotli. 9 | 10 | This allows major bandwidth savings for CSS, JavaScript libraries, fonts, and 11 | other static compressible web content. It also means you can compress the 12 | content without significant runtime penalty. 13 | 14 | ## Example 15 | 16 | Suppose `/var/www/assets/css` contains your style sheets, and you want to make them available as `/css/*.css`: 17 | 18 | ```go 19 | package main 20 | 21 | import ( 22 | "log" 23 | "net/http" 24 | 25 | "github.com/lpar/gzipped/v2" 26 | ) 27 | 28 | func main() { 29 | log.Fatal(http.ListenAndServe(":8080", http.StripPrefix("/css", 30 | gzipped.FileServer(gzipped.Dir("/var/www/assets/css"))))) 31 | // curl localhost:8080/css/styles.css 32 | } 33 | ``` 34 | 35 | Using [httprouter](https://github.com/julienschmidt/httprouter)? 36 | 37 | ```go 38 | router := httprouter.New() 39 | router.Handler("GET", "/css/*filepath", 40 | gzipped.FileServer(gzipped.Dir("/var/www/assets/css")))) 41 | log.Fatal(http.ListenAndServe(":8080", router) 42 | ``` 43 | 44 | An example using `embed` and `fs.FS`: 45 | 46 | ```go 47 | package main 48 | 49 | import ( 50 | "embed" 51 | "log" 52 | "net/http" 53 | 54 | "github.com/lpar/gzipped/v2" 55 | ) 56 | 57 | //go:embed public/* 58 | var public embed.FS 59 | 60 | func main() { 61 | 62 | // Serve the embedded public directory as /public/* 63 | // fs.FS requires that leading / be removed from the incoming file path 64 | http.Handle("/public/", 65 | http.StripPrefix("/", 66 | gzipped.FileServer(gzipped.FS(public)))) 67 | 68 | log.Fatal(http.ListenAndServe(":8080", nil)) 69 | 70 | // curl localhost:8080/public/somefile.html 71 | } 72 | ``` 73 | 74 | ## Change history 75 | 76 | In version 2.0, we require use of `gzipped.Dir`, a drop-in replacement for `http.Dir`. Our `gzipped.Dir` has the 77 | additional feature of letting us check for the existence of files without opening them. This means we can scan 78 | to see what encodings are available, then negotiate that list against the client's preferences, and then only (attempt 79 | to) open and serve the correct file. 80 | 81 | This change means we can let `github.com/kevinpollet/nego` handle the content negotiation, and remove the dependency 82 | on gddo (godoc), which was pulling in 48 dependencies (see [#6](https://github.com/lpar/gzipped/issues/6)). 83 | 84 | ## Detail 85 | 86 | For any given request at `/path/filename.ext`, if: 87 | 88 | 1. There exists a file named `/path/filename.ext.(gz|br)` (starting from the 89 | appropriate base directory), and 90 | 2. the client will accept content compressed via the appropriate algorithm, and 91 | 3. the file can be opened, 92 | 93 | then the compressed file will be served as `/path/filename.ext`, with a 94 | `Content-Encoding` header set so that the client transparently decompresses it. 95 | Otherwise, the request is passed through and handled unchanged. 96 | 97 | Unlike other similar code I found, this package has a license, parses 98 | Accept-Encoding headers properly, and has unit tests. 99 | 100 | ## Caveats 101 | 102 | All requests are passed to Go's standard `http.ServeContent` method for 103 | fulfilment. MIME type mapping, accept ranges, content negotiation and other 104 | tricky details are handled by that method. If the extension of a file doesn't have a defined MIME type, `ServeContent`'s sniffing may result in it assigning a MIME type such as `application/x-gzip` rather than what you might hope. See issue #18 for more information. 105 | 106 | It is up to you to ensure that your compressed and uncompressed resources are 107 | kept in sync. 108 | 109 | Directory browsing isn't supported. That includes remapping URLs ending in `/` to `index.html`, 110 | `index.htm`, `Welcome.html` or whatever — if you want URLs remapped that way, 111 | I suggest having your router do it, or using middleware, so that you have control 112 | over the behavior. For example, to add support for `index.html` files in directories: 113 | 114 | ```go 115 | func withIndexHTML(h http.Handler) http.Handler { 116 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 117 | if strings.HasSuffix(r.URL.Path, "/") || len(r.URL.Path) == 0 { 118 | newpath := path.Join(r.URL.Path, "index.html") 119 | r.URL.Path = newpath 120 | } 121 | h.ServeHTTP(w, r) 122 | }) 123 | } 124 | // ... 125 | 126 | fs := withIndexHTML(gzipped.FileServer(http.Dir("/var/www"))) 127 | ``` 128 | 129 | Or to add support for directory browsing: 130 | 131 | ```go 132 | func withBrowsing(h http.Handler) http.Handler { 133 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 134 | if strings.HasSuffix(r.URL.Path, "/") { 135 | http.ServeFile(w, r, ".") 136 | } 137 | }) 138 | } 139 | // ... 140 | 141 | fs := withBrowsing(gzipped.FileServer(http.Dir("/var/www"))) 142 | ``` 143 | 144 | ## Related 145 | 146 | * You might consider precompressing your CSS with [minify](https://github.com/tdewolff/minify). 147 | 148 | * If you want to get the best possible compression for clients which don't support brotli, use [zopfli](https://github.com/google/zopfli). 149 | 150 | * To compress your dynamically-generated HTML pages on the fly, I suggest [gziphandler](https://github.com/NYTimes/gziphandler). 151 | 152 | -------------------------------------------------------------------------------- /fileserver_test.go: -------------------------------------------------------------------------------- 1 | package gzipped 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "embed" 7 | fs2 "io/fs" 8 | "io/ioutil" 9 | "net/http" 10 | "net/http/httptest" 11 | "net/textproto" 12 | "strconv" 13 | "testing" 14 | 15 | "github.com/kevinpollet/nego" 16 | ) 17 | 18 | // Test that the server respects client preferences 19 | func TestPreference(t *testing.T) { 20 | req := http.Request{Header: http.Header{}} 21 | 22 | // the client doesn't set any preferences, so we should pick br 23 | for _, info := range []struct { 24 | hdr string // the Accept-Encoding string 25 | expect string // the expected encoding chosen by the server 26 | }{ 27 | {"*", "br"}, 28 | {"gzip, deflate, br", "br"}, 29 | {"gzip, deflate, br;q=0.5", "gzip"}, 30 | } { 31 | req.Header.Set("Accept-Encoding", info.hdr) 32 | negenc := nego.NegotiateContentEncoding(&req, preferredEncodings...) 33 | if negenc != info.expect { 34 | t.Errorf("server chose %s but we expected %s for header %s", negenc, info.expect, info.hdr) 35 | } 36 | } 37 | } 38 | 39 | func testGet(t *testing.T, f FileSystem, acceptGzip bool, urlPath string, expectedBody string) { 40 | fs := FileServer(f) 41 | rr := httptest.NewRecorder() 42 | req, _ := http.NewRequest("GET", urlPath, nil) 43 | if acceptGzip { 44 | req.Header.Set("Accept-Encoding", "gzip,*") 45 | } 46 | fs.ServeHTTP(rr, req) 47 | h := rr.Header() 48 | 49 | // Check the content-length is correct. 50 | clh := h["Content-Length"] 51 | if len(clh) > 0 { 52 | byts, err := strconv.Atoi(clh[0]) 53 | if err != nil { 54 | t.Errorf("Invalid Content-Length on response: '%s'", clh[0]) 55 | } 56 | n := rr.Body.Len() 57 | if n != byts { 58 | t.Errorf("GET expected %d byts, got %d", byts, n) 59 | } 60 | } 61 | 62 | // Check the body content is correct. 63 | ce := h["Content-Encoding"] 64 | var body string 65 | if len(ce) > 0 { 66 | if ce[0] == "gzip" { 67 | rdr, err := gzip.NewReader(bytes.NewReader(rr.Body.Bytes())) 68 | if err != nil { 69 | t.Errorf("Gunzip failed: %s", err) 70 | } else { 71 | bbody, err := ioutil.ReadAll(rdr) 72 | if err != nil { 73 | t.Errorf("Gunzip read failed: %s", err) 74 | } else { 75 | body = string(bbody) 76 | } 77 | } 78 | } else { 79 | t.Errorf("Invalid Content-Encoding in response: '%s'", ce[0]) 80 | } 81 | } else { 82 | body = rr.Body.String() 83 | } 84 | if len(body) != len(expectedBody) { 85 | t.Errorf("GET (acceptGzip=%v) returned wrong decoded body length %d, expected %d", 86 | acceptGzip, len(body), len(expectedBody)) 87 | } 88 | if body != expectedBody { 89 | t.Errorf("GET (acceptGzip=%v) returned wrong body '%s'", acceptGzip, body) 90 | } 91 | } 92 | 93 | //go:embed testdata 94 | var testData embed.FS 95 | 96 | type TestCase struct { 97 | name string 98 | test func(t *testing.T) 99 | } 100 | 101 | func TestFileServer(t *testing.T) { 102 | tests := func(f FileSystem) []TestCase { 103 | return []TestCase{ 104 | { 105 | name: "OpenStat", 106 | test: func(t *testing.T) { 107 | fh := &fileHandler{f} 108 | _, _, err := fh.openAndStat(".") 109 | if err == nil { 110 | t.Errorf("openAndStat directory succeeded, should have failed") 111 | } 112 | _, _, err = fh.openAndStat("updog") 113 | if err == nil { 114 | t.Errorf("openAndStat nonexistent file succeeded, should have failed") 115 | } 116 | }, 117 | }, 118 | { 119 | 120 | name: "NoBrowse", 121 | test: func(t *testing.T) { 122 | fs := FileServer(f) 123 | rr := httptest.NewRecorder() 124 | req, _ := http.NewRequest("GET", "/", nil) 125 | fs.ServeHTTP(rr, req) 126 | if rr.Code != 404 { 127 | t.Errorf("Directory browse succeeded") 128 | } 129 | }, 130 | }, 131 | { 132 | 133 | name: "LeadingSlash", 134 | test: func(t *testing.T) { 135 | fs := FileServer(f) 136 | rr := httptest.NewRecorder() 137 | req, _ := http.NewRequest("GET", "file.txt", nil) 138 | fs.ServeHTTP(rr, req) 139 | if rr.Code != 200 { 140 | t.Errorf("Missing leading / on HTTP path caused error") 141 | } 142 | }, 143 | }, 144 | { 145 | 146 | name: "404", 147 | test: func(t *testing.T) { 148 | fs := FileServer(f) 149 | rr := httptest.NewRecorder() 150 | req, _ := http.NewRequest("GET", "/nonexistent.txt", nil) 151 | fs.ServeHTTP(rr, req) 152 | if rr.Code != 404 { 153 | t.Errorf("Directory browse succeeded") 154 | } 155 | }, 156 | }, 157 | { 158 | 159 | name: "Get", 160 | test: func(t *testing.T) { 161 | testGet(t, f, false, "/file.txt", "zyxwvutsrqponmlkjihgfedcba\n") 162 | }, 163 | }, 164 | { 165 | 166 | name: "GzipGet", 167 | test: func(t *testing.T) { 168 | testGet(t, f, true, "/file.txt", "abcdefghijklmnopqrstuvwxyz\n") 169 | }, 170 | }, 171 | { 172 | 173 | name: "GetIdentity", 174 | test: func(t *testing.T) { 175 | testGet(t, f, false, "/file2.txt", "1234567890987654321\n") 176 | }, 177 | }, 178 | { 179 | 180 | name: "GzipGetIdentity", 181 | test: func(t *testing.T) { 182 | testGet(t, f, true, "/file2.txt", "1234567890987654321\n") 183 | }, 184 | }, 185 | } 186 | } 187 | 188 | sub, err := fs2.Sub(testData, "testdata") 189 | if err != nil { 190 | t.Fatal(err) 191 | } 192 | 193 | for name, fs := range map[string]FileSystem{"dir": Dir("./testdata/"), "fs": FS(sub)} { 194 | t.Run(name, func(t *testing.T) { 195 | for _, tt := range tests(fs) { 196 | t.Run(tt.name, tt.test) 197 | } 198 | }) 199 | } 200 | } 201 | 202 | func TestConstHeaders(t *testing.T) { 203 | for _, header := range []string{ 204 | acceptEncodingHeader, 205 | contentEncodingHeader, 206 | contentLengthHeader, 207 | rangeHeader, 208 | varyHeader, 209 | } { 210 | canonical := textproto.CanonicalMIMEHeaderKey(header) 211 | if header != canonical { 212 | t.Errorf("%s != %s", header, canonical) 213 | } 214 | } 215 | } 216 | --------------------------------------------------------------------------------