├── .gitignore ├── .travis.yml ├── LICENSE.md ├── Makefile ├── README.md ├── buf_pool.go ├── example_test.go ├── file_server.go ├── file_server_test.go ├── file_system.go ├── file_system_test.go ├── go.mod ├── go.sum ├── testdata ├── img │ ├── another-circle.png │ └── circle.png ├── index.html ├── js │ └── application-23a0.js ├── lots-of-files │ ├── file-01 │ ├── file-02 │ ├── file-03 │ ├── file-04 │ ├── file-05 │ ├── file-06 │ ├── file-07 │ ├── file-08 │ ├── file-09 │ ├── file-10 │ ├── file-11 │ ├── file-12 │ ├── file-13 │ ├── file-14 │ ├── file-15 │ ├── file-16 │ ├── file-17 │ ├── file-18 │ ├── file-19 │ └── file-20 ├── not-a-zip-file.txt ├── random.dat ├── test.html └── testdata.zip └── zipfs.go /.gitignore: -------------------------------------------------------------------------------- 1 | coverage.out 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - "1.12" 5 | - "1.11" 6 | 7 | install: 8 | - go get github.com/stretchr/testify/assert 9 | - go get github.com/stretchr/testify/require 10 | - go get golang.org/x/tools/cmd/cover 11 | - go get github.com/mattn/goveralls 12 | 13 | script: 14 | - go test -v -covermode=count -coverprofile=coverage.out 15 | - $(go env GOPATH | awk 'BEGIN{FS=":"} {print $1}')/bin/goveralls -coverprofile=coverage.out -service=travis-ci 16 | 17 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 The Go Authors. 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 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google Inc. nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: \ 2 | build \ 3 | test \ 4 | cover 5 | 6 | build: 7 | go build 8 | 9 | test: 10 | go test 11 | 12 | cover: 13 | go test -coverprofile coverage.out 14 | go tool cover -html coverage.out 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ZIP-based File System for serving HTTP requests 2 | 3 | [![GoDoc](https://godoc.org/github.com/spkg/zipfs?status.svg)](https://godoc.org/github.com/spkg/zipfs) 4 | [![Build Status (Linux)](https://travis-ci.org/spkg/zipfs.svg?branch=master)](https://travis-ci.org/spkg/zipfs) 5 | [![Build status (Windows)](https://ci.appveyor.com/api/projects/status/tko2unyo9wm172e1?svg=true)](https://ci.appveyor.com/project/jjeffery/zipfs) 6 | [![Coverage Status](https://coveralls.io/repos/github/spkg/zipfs/badge.svg?branch=master)](https://coveralls.io/github/spkg/zipfs?branch=master) 7 | [![GoReportCard](https://goreportcard.com/badge/github.com/spkg/zipfs)](https://goreportcard.com/report/github.com/spkg/zipfs) 8 | [![License](https://img.shields.io/badge/license-BSD-green.svg)](https://raw.githubusercontent.com/spkg/zipfs/master/LICENSE.md) 9 | 10 | Package `zipfs` provides a convenient way for a HTTP server to serve 11 | static content from a ZIP file. 12 | 13 | Usage is simple. See the example in the 14 | [GoDoc](https://godoc.org/github.com/spkg/zipfs) documentation. 15 | 16 | ## License 17 | 18 | Some of the code in this project is based on code in the `net/http` 19 | package in the Go standard library. For this reason, this package has 20 | the same license as the Go standard library. 21 | -------------------------------------------------------------------------------- /buf_pool.go: -------------------------------------------------------------------------------- 1 | package zipfs 2 | 3 | import "sync" 4 | 5 | type buffer [32768]byte 6 | 7 | var bufPool struct { 8 | Get func() *buffer // Allocate a buffer 9 | Free func(*buffer) // Free the buffer 10 | } 11 | 12 | func init() { 13 | var pool sync.Pool 14 | 15 | bufPool.Get = func() *buffer { 16 | b, ok := pool.Get().(*buffer) 17 | if !ok { 18 | b = new(buffer) 19 | } 20 | return b 21 | } 22 | 23 | bufPool.Free = func(b *buffer) { 24 | if b != nil { 25 | pool.Put(b) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package zipfs_test 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/spkg/zipfs" 7 | ) 8 | 9 | func Example() error { 10 | fs, err := zipfs.New("testdata/testdata.zip") 11 | if err != nil { 12 | return err 13 | } 14 | 15 | return http.ListenAndServe(":8080", zipfs.FileServer(fs)) 16 | } 17 | -------------------------------------------------------------------------------- /file_server.go: -------------------------------------------------------------------------------- 1 | package zipfs 2 | 3 | // Some of the functions in this file are adapted from private 4 | // functions in the standard library net/http package. 5 | // 6 | // Copyright 2009 The Go Authors. All rights reserved. 7 | // Use of this source code is governed by a BSD-style 8 | // license that can be found in the LICENSE.md file. 9 | 10 | import ( 11 | "archive/zip" 12 | "fmt" 13 | "io" 14 | "mime" 15 | "net/http" 16 | "os" 17 | "path" 18 | "path/filepath" 19 | "strings" 20 | "time" 21 | ) 22 | 23 | // FileServer returns a HTTP handler that serves 24 | // HTTP requests with the contents of the ZIP file system. 25 | // It provides slightly better performance than the 26 | // http.FileServer implementation because it serves compressed content 27 | // to clients that can accept the "deflate" compression algorithm. 28 | func FileServer(fs *FileSystem) http.Handler { 29 | h := &fileHandler{ 30 | fs: fs, 31 | } 32 | 33 | return h 34 | } 35 | 36 | type fileHandler struct { 37 | fs *FileSystem 38 | } 39 | 40 | func (h *fileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 41 | upath := r.URL.Path 42 | if !strings.HasPrefix(upath, "/") { 43 | upath = "/" + upath 44 | r.URL.Path = upath 45 | } 46 | 47 | serveFile(w, r, h.fs, path.Clean(upath), true) 48 | } 49 | 50 | // name is '/'-separated, not filepath.Separator. 51 | func serveFile(w http.ResponseWriter, r *http.Request, fs *FileSystem, name string, redirect bool) { 52 | const indexPage = "/index.html" 53 | 54 | // redirect .../index.html to .../ 55 | // can't use Redirect() because that would make the path absolute, 56 | // which would be a problem running under StripPrefix 57 | if strings.HasSuffix(r.URL.Path, indexPage) { 58 | localRedirect(w, r, "./") 59 | return 60 | } 61 | 62 | d, err := fs.openFileInfo(name) 63 | if err != nil { 64 | msg, code := toHTTPError(err) 65 | http.Error(w, msg, code) 66 | return 67 | } 68 | 69 | if redirect { 70 | // redirect to canonical path: / at end of directory url 71 | // r.URL.Path always begins with / 72 | url := r.URL.Path 73 | if d.IsDir() { 74 | if url[len(url)-1] != '/' { 75 | localRedirect(w, r, path.Base(url)+"/") 76 | return 77 | } 78 | } else { 79 | if url[len(url)-1] == '/' { 80 | localRedirect(w, r, "../"+path.Base(url)) 81 | return 82 | } 83 | } 84 | } 85 | 86 | // use contents of index.html for directory, if present 87 | if d.IsDir() { 88 | index := strings.TrimSuffix(name, "/") + indexPage 89 | dd, err := fs.openFileInfo(index) 90 | if err == nil { 91 | d = dd 92 | } 93 | } 94 | 95 | // Still a directory? (we didn't find an index.html file) 96 | if d.IsDir() { 97 | // Unlike the standard library implementation, directory 98 | // listing is prohibited. 99 | http.Error(w, "Forbidden", http.StatusForbidden) 100 | return 101 | } 102 | 103 | // serveContent will check modification time and ETag 104 | serveContent(w, r, fs, d) 105 | } 106 | 107 | func serveContent(w http.ResponseWriter, r *http.Request, fs *FileSystem, fi *fileInfo) { 108 | if checkLastModified(w, r, fi.ModTime()) { 109 | return 110 | } 111 | 112 | // Set the Etag header in the response before calling checkETag. 113 | // The checkETag function obtains the files ETag from the response header. 114 | w.Header().Set("Etag", calcEtag(fi.zipFile)) 115 | rangeReq, done := checkETag(w, r, fi.ModTime()) 116 | if done { 117 | return 118 | } 119 | if rangeReq != "" { 120 | // Range request requires seeking, so at this point create a temporary 121 | // file and let the standard library serve it. 122 | f := fi.openReader(r.URL.Path) 123 | defer f.Close() 124 | f.createTempFile() 125 | http.ServeContent(w, r, fi.Name(), fi.ModTime(), f.file) 126 | return 127 | } 128 | 129 | setContentType(w, fi.Name()) 130 | 131 | switch fi.zipFile.Method { 132 | case zip.Store: 133 | serveIdentity(w, r, fi.zipFile) 134 | case zip.Deflate: 135 | serveDeflate(w, r, fi.zipFile, fs.readerAt) 136 | default: 137 | http.Error(w, fmt.Sprintf("unsupported zip method: %d", fi.zipFile.Method), http.StatusInternalServerError) 138 | } 139 | } 140 | 141 | // serveIdentity serves a zip file in identity content encoding . 142 | func serveIdentity(w http.ResponseWriter, r *http.Request, zf *zip.File) { 143 | // TODO: need to check if the client explicitly refuses to accept 144 | // identity encoding (Accept-Encoding: identity;q=0), but this is 145 | // going to be very rare. 146 | 147 | reader, err := zf.Open() 148 | if err != nil { 149 | msg, code := toHTTPError(err) 150 | http.Error(w, msg, code) 151 | return 152 | } 153 | defer reader.Close() 154 | 155 | size := zf.FileInfo().Size() 156 | w.Header().Del("Content-Encoding") 157 | w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) 158 | if r.Method != "HEAD" { 159 | io.CopyN(w, reader, int64(size)) 160 | } 161 | } 162 | 163 | // serveDeflat serves a zip file in deflate content-encoding if the 164 | // user agent can accept it. Otherwise it calls serveIdentity. 165 | func serveDeflate(w http.ResponseWriter, r *http.Request, f *zip.File, readerAt io.ReaderAt) { 166 | acceptEncoding := r.Header.Get("Accept-Encoding") 167 | 168 | // TODO: need to parse the accept header to work out if the 169 | // client is explicitly forbidding deflate (ie deflate;q=0) 170 | acceptsDeflate := strings.Contains(acceptEncoding, "deflate") 171 | if !acceptsDeflate { 172 | // client will not accept deflate, so serve as identity 173 | serveIdentity(w, r, f) 174 | return 175 | } 176 | 177 | contentLength := int64(f.CompressedSize64) 178 | if contentLength == 0 { 179 | contentLength = int64(f.CompressedSize) 180 | } 181 | w.Header().Set("Content-Encoding", "deflate") 182 | w.Header().Set("Content-Length", fmt.Sprintf("%d", contentLength)) 183 | if r.Method == "HEAD" { 184 | return 185 | } 186 | 187 | var written int64 188 | remaining := contentLength 189 | offset, err := f.DataOffset() 190 | if err != nil { 191 | msg, code := toHTTPError(err) 192 | http.Error(w, msg, code) 193 | return 194 | } 195 | 196 | // re-use buffers to reduce stress on GC 197 | buf := bufPool.Get() 198 | defer bufPool.Free(buf) 199 | 200 | // loop to write the raw deflated content to the client 201 | for remaining > 0 { 202 | size := len(buf) 203 | if int64(size) > remaining { 204 | size = int(remaining) 205 | } 206 | 207 | b := buf[:size] 208 | _, err := readerAt.ReadAt(b, offset) 209 | if err != nil { 210 | if written == 0 { 211 | // have not written anything to the client yet, so we can send an error 212 | msg, code := toHTTPError(err) 213 | http.Error(w, msg, code) 214 | } 215 | return 216 | } 217 | if _, err := w.Write(b); err != nil { 218 | // Cannot write an error to the client because, er, we just 219 | // failed to write to the client. 220 | return 221 | } 222 | written += int64(size) 223 | remaining -= int64(size) 224 | offset += int64(size) 225 | } 226 | } 227 | 228 | func setContentType(w http.ResponseWriter, filename string) { 229 | ctypes, haveType := w.Header()["Content-Type"] 230 | var ctype string 231 | if !haveType { 232 | ctype = mime.TypeByExtension(filepath.Ext(path.Base(filename))) 233 | if ctype == "" { 234 | // the standard library sniffs content to decide whether it is 235 | // binary or text, but this requires a ReaderSeeker, and we 236 | // only have a reader from the zip file. Assume binary. 237 | ctype = "application/octet-stream" 238 | } 239 | } else if len(ctypes) > 0 { 240 | ctype = ctypes[0] 241 | } 242 | if ctype != "" { 243 | w.Header().Set("Content-Type", ctype) 244 | } 245 | } 246 | 247 | // calcEtag calculates an ETag value for a given zip file based on 248 | // the file's CRC and its length. 249 | func calcEtag(f *zip.File) string { 250 | size := f.UncompressedSize64 251 | if size == 0 { 252 | size = uint64(f.UncompressedSize) 253 | } 254 | etag := uint64(f.CRC32) ^ (uint64(size&0xffffffff) << 32) 255 | 256 | // etag should always be in double quotes 257 | return fmt.Sprintf(`"%x"`, etag) 258 | } 259 | 260 | var unixEpochTime = time.Unix(0, 0) 261 | 262 | // modtime is the modification time of the resource to be served, or IsZero(). 263 | // return value is whether this request is now complete. 264 | func checkLastModified(w http.ResponseWriter, r *http.Request, modtime time.Time) bool { 265 | if modtime.IsZero() || modtime.Equal(unixEpochTime) { 266 | // If the file doesn't have a modtime (IsZero), or the modtime 267 | // is obviously garbage (Unix time == 0), then ignore modtimes 268 | // and don't process the If-Modified-Since header. 269 | return false 270 | } 271 | 272 | // The Date-Modified header truncates sub-second precision, so 273 | // use mtime < t+1s instead of mtime <= t to check for unmodified. 274 | if t, err := time.Parse(http.TimeFormat, r.Header.Get("If-Modified-Since")); err == nil && modtime.Before(t.Add(1*time.Second)) { 275 | h := w.Header() 276 | delete(h, "Content-Type") 277 | delete(h, "Content-Length") 278 | w.WriteHeader(http.StatusNotModified) 279 | return true 280 | } 281 | w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat)) 282 | return false 283 | } 284 | 285 | // checkETag implements If-None-Match and If-Range checks. 286 | // 287 | // The ETag or modtime must have been previously set in the 288 | // ResponseWriter's headers. The modtime is only compared at second 289 | // granularity and may be the zero value to mean unknown. 290 | // 291 | // The return value is the effective request "Range" header to use and 292 | // whether this request is now considered done. 293 | func checkETag(w http.ResponseWriter, r *http.Request, modtime time.Time) (rangeReq string, done bool) { 294 | etag := w.Header().Get("Etag") 295 | rangeReq = r.Header.Get("Range") 296 | 297 | // Invalidate the range request if the entity doesn't match the one 298 | // the client was expecting. 299 | // "If-Range: version" means "ignore the Range: header unless version matches the 300 | // current file." 301 | // We only support ETag versions. 302 | // The caller must have set the ETag on the response already. 303 | if ir := r.Header.Get("If-Range"); ir != "" && ir != etag { 304 | // The If-Range value is typically the ETag value, but it may also be 305 | // the modtime date. See golang.org/issue/8367. 306 | timeMatches := false 307 | if !modtime.IsZero() { 308 | if t, err := http.ParseTime(ir); err == nil && t.Unix() == modtime.Unix() { 309 | timeMatches = true 310 | } 311 | } 312 | if !timeMatches { 313 | rangeReq = "" 314 | } 315 | } 316 | 317 | if inm := r.Header.Get("If-None-Match"); inm != "" { 318 | // Must know ETag. 319 | if etag == "" { 320 | return rangeReq, false 321 | } 322 | 323 | // TODO(bradfitz): non-GET/HEAD requests require more work: 324 | // sending a different status code on matches, and 325 | // also can't use weak cache validators (those with a "W/ 326 | // prefix). But most users of ServeContent will be using 327 | // it on GET or HEAD, so only support those for now. 328 | if r.Method != "GET" && r.Method != "HEAD" { 329 | return rangeReq, false 330 | } 331 | 332 | // TODO(bradfitz): deal with comma-separated or multiple-valued 333 | // list of If-None-match values. For now just handle the common 334 | // case of a single item. 335 | if inm == etag || inm == "*" { 336 | h := w.Header() 337 | delete(h, "Content-Type") 338 | delete(h, "Content-Length") 339 | w.WriteHeader(http.StatusNotModified) 340 | return "", true 341 | } 342 | } 343 | return rangeReq, false 344 | } 345 | 346 | // toHTTPError returns a non-specific HTTP error message and status code 347 | // for a given non-nil error value. It's important that toHTTPError does not 348 | // actually return err.Error(), since msg and httpStatus are returned to users, 349 | // and historically Go's ServeContent always returned just "404 Not Found" for 350 | // all errors. We don't want to start leaking information in error messages. 351 | func toHTTPError(err error) (msg string, httpStatus int) { 352 | if pathErr, ok := err.(*os.PathError); ok { 353 | err = pathErr.Err 354 | } 355 | if os.IsNotExist(err) { 356 | return "404 page not found", http.StatusNotFound 357 | } 358 | if os.IsPermission(err) { 359 | return "403 Forbidden", http.StatusForbidden 360 | } 361 | // Default: 362 | return "500 Internal Server Error", http.StatusInternalServerError 363 | } 364 | 365 | // localRedirect gives a Moved Permanently response. 366 | // It does not convert relative paths to absolute paths like Redirect does. 367 | func localRedirect(w http.ResponseWriter, r *http.Request, newPath string) { 368 | if q := r.URL.RawQuery; q != "" { 369 | newPath += "?" + q 370 | } 371 | w.Header().Set("Location", newPath) 372 | w.WriteHeader(http.StatusMovedPermanently) 373 | } 374 | -------------------------------------------------------------------------------- /file_server_test.go: -------------------------------------------------------------------------------- 1 | package zipfs 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "mime" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "strings" 12 | "testing" 13 | "time" 14 | 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | type TestResponseWriter struct { 20 | header http.Header 21 | status int 22 | buf bytes.Buffer 23 | } 24 | 25 | func NewTestResponseWriter() *TestResponseWriter { 26 | return &TestResponseWriter{ 27 | header: make(http.Header), 28 | status: 200, 29 | } 30 | } 31 | 32 | func (w *TestResponseWriter) Header() http.Header { 33 | return w.header 34 | } 35 | 36 | func (w *TestResponseWriter) Write(b []byte) (int, error) { 37 | return w.buf.Write(b) 38 | } 39 | 40 | func (w *TestResponseWriter) WriteHeader(status int) { 41 | w.status = status 42 | } 43 | 44 | func TestNew(t *testing.T) { 45 | assert := assert.New(t) 46 | testCases := []struct { 47 | Name string 48 | Error string 49 | }{ 50 | { 51 | Name: "testdata/does-not-exist.zip", 52 | Error: "The system cannot find the file specified", 53 | }, 54 | { 55 | Name: "testdata/testdata.zip", 56 | Error: "", 57 | }, 58 | { 59 | Name: "testdata/not-a-zip-file.txt", 60 | Error: "zip: not a valid zip file", 61 | }, 62 | } 63 | 64 | for _, tc := range testCases { 65 | fs, err := New(tc.Name) 66 | if tc.Error != "" { 67 | assert.Error(err) 68 | //assert.True(strings.Contains(err.Error(), tc.Error), err.Error()) 69 | assert.Nil(fs) 70 | } else { 71 | assert.NoError(err) 72 | assert.NotNil(fs) 73 | } 74 | if fs != nil { 75 | fs.Close() 76 | } 77 | } 78 | } 79 | 80 | func TestServeHTTP(t *testing.T) { 81 | assert := assert.New(t) 82 | require := require.New(t) 83 | 84 | fs, err := New("testdata/testdata.zip") 85 | require.NoError(err) 86 | require.NotNil(fs) 87 | 88 | handler := FileServer(fs) 89 | 90 | testCases := []struct { 91 | Path string 92 | Headers []string 93 | Status int 94 | ContentType string 95 | ContentLength string 96 | ContentEncoding string 97 | ETag string 98 | Size int 99 | Location string 100 | }{ 101 | { 102 | Path: "/img/circle.png", 103 | Status: 200, 104 | Headers: []string{ 105 | "Accept-Encoding: deflate, gzip", 106 | }, 107 | ContentType: "image/png", 108 | ContentLength: "4758", 109 | ContentEncoding: "deflate", 110 | Size: 4758, 111 | ETag: `"1755529fb2ff"`, 112 | }, 113 | { 114 | Path: "/img/circle.png", 115 | Status: 200, 116 | Headers: []string{ 117 | "Accept-Encoding: gzip", 118 | }, 119 | ContentType: "image/png", 120 | ContentLength: "5973", 121 | ContentEncoding: "", 122 | Size: 5973, 123 | ETag: `"1755529fb2ff"`, 124 | }, 125 | { 126 | Path: "/", 127 | Status: 200, 128 | Headers: []string{ 129 | "Accept-Encoding: deflate, gzip", 130 | }, 131 | ContentType: "text/html; charset=utf-8", 132 | ContentEncoding: "deflate", 133 | }, 134 | { 135 | Path: "/test.html", 136 | Status: 200, 137 | Headers: []string{}, 138 | ContentType: "text/html; charset=utf-8", 139 | ContentEncoding: "", 140 | }, 141 | { 142 | Path: "/does/not/exist", 143 | Status: 404, 144 | Headers: []string{ 145 | "Accept-Encoding: deflate, gzip", 146 | }, 147 | ContentType: "text/plain; charset=utf-8", 148 | }, 149 | { 150 | Path: "/random.dat", 151 | Status: 200, 152 | Headers: []string{ 153 | "Accept-Encoding: deflate", 154 | }, 155 | ContentType: getMimeType(".dat"), 156 | ContentLength: "10000", 157 | ContentEncoding: "", 158 | Size: 10000, 159 | ETag: `"27106c15f45b"`, 160 | }, 161 | { 162 | Path: "/random.dat", 163 | Status: 200, 164 | Headers: []string{}, 165 | ContentType: getMimeType(".dat"), 166 | ContentLength: "10000", 167 | ContentEncoding: "", 168 | Size: 10000, 169 | ETag: `"27106c15f45b"`, 170 | }, 171 | { 172 | Path: "/random.dat", 173 | Status: 206, 174 | Headers: []string{ 175 | `If-Range: "27106c15f45b"`, 176 | "Range: bytes=0-499", 177 | }, 178 | ContentType: getMimeType(".dat"), 179 | ContentLength: "500", 180 | ContentEncoding: "", 181 | Size: 500, 182 | ETag: `"27106c15f45b"`, 183 | }, 184 | { 185 | Path: "/random.dat", 186 | Status: 200, 187 | Headers: []string{ 188 | `If-Range: "123456789"`, 189 | "Range: bytes=0-499", 190 | "Accept-Encoding: deflate, gzip", 191 | }, 192 | ContentType: getMimeType(".dat"), 193 | ContentLength: "10000", 194 | ContentEncoding: "", 195 | Size: 10000, 196 | ETag: `"27106c15f45b"`, 197 | }, 198 | { 199 | Path: "/random.dat", 200 | Status: 304, 201 | Headers: []string{ 202 | `If-None-Match: "27106c15f45b"`, 203 | "Accept-Encoding: deflate, gzip", 204 | }, 205 | ContentType: "", 206 | ContentLength: "", 207 | ContentEncoding: "", 208 | Size: 0, 209 | ETag: `"27106c15f45b"`, 210 | }, 211 | { 212 | Path: "/random.dat", 213 | Status: 304, 214 | Headers: []string{ 215 | fmt.Sprintf("If-Modified-Since: %s", time.Now().UTC().Add(time.Hour*10000).Format(http.TimeFormat)), 216 | "Accept-Encoding: deflate, gzip", 217 | }, 218 | ContentType: "", 219 | ContentLength: "", 220 | ContentEncoding: "", 221 | Size: 0, 222 | }, 223 | { 224 | Path: "random.dat", 225 | Status: 200, 226 | Headers: []string{}, 227 | ContentType: getMimeType(".dat"), 228 | ContentLength: "10000", 229 | Size: 10000, 230 | ETag: `"27106c15f45b"`, 231 | }, 232 | { 233 | Path: "/index.html", 234 | Status: 301, 235 | Headers: []string{}, 236 | Location: "./", 237 | }, 238 | { 239 | Path: "/empty", 240 | Status: 301, 241 | Headers: []string{}, 242 | Location: "empty/", 243 | }, 244 | { 245 | Path: "/img/circle.png/", 246 | Status: 301, 247 | Headers: []string{}, 248 | Location: "../circle.png", 249 | }, 250 | { 251 | Path: "/empty/", 252 | Status: 403, 253 | ContentType: "text/plain; charset=utf-8", 254 | Headers: []string{}, 255 | }, 256 | } 257 | 258 | for _, tc := range testCases { 259 | req := &http.Request{ 260 | URL: &url.URL{ 261 | Scheme: "http", 262 | Host: "test-server.com", 263 | Path: tc.Path, 264 | }, 265 | Header: make(http.Header), 266 | Method: "GET", 267 | } 268 | 269 | for _, header := range tc.Headers { 270 | arr := strings.SplitN(header, ":", 2) 271 | key := strings.TrimSpace(arr[0]) 272 | value := strings.TrimSpace(arr[1]) 273 | req.Header.Add(key, value) 274 | } 275 | 276 | w := NewTestResponseWriter() 277 | handler.ServeHTTP(w, req) 278 | 279 | assert.Equal(tc.Status, w.status, tc.Path) 280 | assert.Equal(tc.ContentType, w.Header().Get("Content-Type"), tc.Path) 281 | if tc.ContentLength != "" { 282 | // only check content length for non-text because length will differ 283 | // between windows and unix 284 | assert.Equal(tc.ContentLength, w.Header().Get("Content-Length"), tc.Path) 285 | } 286 | assert.Equal(tc.ContentEncoding, w.Header().Get("Content-Encoding"), tc.Path) 287 | if tc.Size > 0 { 288 | assert.Equal(tc.Size, w.buf.Len(), tc.Path) 289 | } 290 | if tc.ETag != "" { 291 | // only check ETag for non-text files because CRC will differ between 292 | // windows and unix 293 | assert.Equal(tc.ETag, w.Header().Get("Etag"), tc.Path) 294 | } 295 | if tc.Location != "" { 296 | assert.Equal(tc.Location, w.Header().Get("Location"), tc.Path) 297 | } 298 | } 299 | } 300 | 301 | func TestToHTTPError(t *testing.T) { 302 | assert := assert.New(t) 303 | 304 | testCases := []struct { 305 | Err error 306 | Message string 307 | Status int 308 | }{ 309 | { 310 | Err: os.ErrNotExist, 311 | Message: "404 page not found", 312 | Status: 404, 313 | }, 314 | { 315 | Err: os.ErrPermission, 316 | Message: "403 Forbidden", 317 | Status: 403, 318 | }, 319 | { 320 | Err: errors.New("test error condition"), 321 | Message: "500 Internal Server Error", 322 | Status: 500, 323 | }, 324 | } 325 | 326 | for _, tc := range testCases { 327 | msg, code := toHTTPError(tc.Err) 328 | assert.Equal(tc.Message, msg, tc.Err.Error()) 329 | assert.Equal(tc.Status, code, tc.Err.Error()) 330 | msg, code = toHTTPError(&os.PathError{Op: "op", Path: "path", Err: tc.Err}) 331 | assert.Equal(tc.Message, msg, tc.Err.Error()) 332 | assert.Equal(tc.Status, code, tc.Err.Error()) 333 | } 334 | } 335 | 336 | func TestLocalRedirect(t *testing.T) { 337 | assert := assert.New(t) 338 | 339 | testCases := []struct { 340 | Url string 341 | NewPath string 342 | Location string 343 | }{ 344 | { 345 | Url: "/test", 346 | NewPath: "./test/", 347 | Location: "./test/", 348 | }, 349 | { 350 | Url: "/test?a=32&b=54", 351 | NewPath: "./test/", 352 | Location: "./test/?a=32&b=54", 353 | }, 354 | } 355 | 356 | for _, tc := range testCases { 357 | u, err := url.Parse(tc.Url) 358 | assert.NoError(err) 359 | r := &http.Request{ 360 | URL: u, 361 | } 362 | w := NewTestResponseWriter() 363 | localRedirect(w, r, tc.NewPath) 364 | assert.Equal(http.StatusMovedPermanently, w.status) 365 | assert.Equal(tc.Location, w.Header().Get("Location")) 366 | } 367 | } 368 | 369 | func TestCheckETag(t *testing.T) { 370 | assert := assert.New(t) 371 | 372 | testCases := []struct { 373 | ModTime time.Time 374 | Method string 375 | Etag string 376 | Range string 377 | IfRange string 378 | IfNoneMatch string 379 | ContentType string 380 | ContentLength string 381 | 382 | RangeReq string 383 | Done bool 384 | }{ 385 | { 386 | // Using the modified time instead of the ETag in If-Range header 387 | // If-None-Match is not set. 388 | ModTime: time.Date(2006, 4, 12, 15, 4, 5, 0, time.UTC), 389 | Method: "GET", 390 | Etag: `"xxxxyyyy"`, 391 | Range: "bytes=500-999", 392 | IfRange: `Wed, 12 Apr 2006 15:04:05 GMT`, 393 | ContentType: "text/html", 394 | ContentLength: "2024", 395 | 396 | RangeReq: "bytes=500-999", 397 | Done: false, 398 | }, 399 | { 400 | // Using the modified time instead of the ETag in If-Range header 401 | // If-None-Match is set. 402 | ModTime: time.Date(2006, 4, 12, 15, 4, 5, 0, time.UTC), 403 | Method: "GET", 404 | Etag: `"xxxxyyyy"`, 405 | Range: "bytes=500-999", 406 | IfRange: `Wed, 12 Apr 2006 15:04:05 GMT`, 407 | IfNoneMatch: `"xxxxyyyy"`, 408 | ContentType: "text/html", 409 | ContentLength: "2024", 410 | 411 | RangeReq: "", 412 | Done: true, 413 | }, 414 | { 415 | // ETag not set, but If-None-Match is. 416 | ModTime: time.Date(2006, 4, 12, 15, 4, 5, 0, time.UTC), 417 | Method: "GET", 418 | IfNoneMatch: `"xxxxyyyy"`, 419 | ContentType: "text/html", 420 | ContentLength: "2024", 421 | 422 | RangeReq: "", 423 | Done: false, 424 | }, 425 | { 426 | // ETag matches If-None-Match, but method is not GET or HEAD 427 | ModTime: time.Date(2006, 4, 12, 15, 4, 5, 0, time.UTC), 428 | Method: "POST", 429 | Etag: `"xxxxyyyy"`, 430 | IfNoneMatch: `"xxxxyyyy"`, 431 | ContentType: "text/html", 432 | ContentLength: "2024", 433 | 434 | RangeReq: "", 435 | Done: false, 436 | }, 437 | { 438 | // Using the ETag in the If-Range header 439 | ModTime: time.Date(2006, 4, 12, 15, 4, 5, 0, time.UTC), 440 | Method: "GET", 441 | Etag: `"xxxxyyyy"`, 442 | Range: "bytes=500-999", 443 | IfRange: `"xxxxyyyy"`, 444 | ContentType: "text/html", 445 | ContentLength: "2024", 446 | 447 | RangeReq: "bytes=500-999", 448 | Done: false, 449 | }, 450 | { 451 | // Using an out of date ETag in the If-Range header 452 | ModTime: time.Date(2006, 4, 12, 15, 4, 5, 0, time.UTC), 453 | Method: "GET", 454 | Etag: `"xxxxyyyy"`, 455 | Range: "bytes=500-999", 456 | IfRange: `"aaaabbbb"`, 457 | ContentType: "text/html", 458 | ContentLength: "2024", 459 | 460 | RangeReq: "", 461 | Done: false, 462 | }, 463 | { 464 | // Using an out of date ETag in the If-Range header 465 | ModTime: time.Date(2006, 4, 12, 15, 4, 5, 0, time.UTC), 466 | Method: "GET", 467 | Etag: `"xxxxyyyy"`, 468 | Range: "bytes=500-999", 469 | IfRange: `"aaaabbbb"`, 470 | ContentType: "text/html", 471 | ContentLength: "2024", 472 | 473 | RangeReq: "", 474 | Done: false, 475 | }, 476 | } 477 | 478 | for i, tc := range testCases { 479 | r := &http.Request{Method: tc.Method, Header: http.Header{}} 480 | w := NewTestResponseWriter() 481 | if tc.Etag != "" { 482 | w.Header().Add("Etag", tc.Etag) 483 | } 484 | if tc.Range != "" { 485 | r.Header.Add("Range", tc.Range) 486 | } 487 | if tc.IfRange != "" { 488 | r.Header.Add("If-Range", tc.IfRange) 489 | } 490 | if tc.IfNoneMatch != "" { 491 | r.Header.Add("If-None-Match", tc.IfNoneMatch) 492 | } 493 | if tc.ContentType != "" { 494 | w.Header().Add("Content-Type", tc.ContentType) 495 | } 496 | if tc.ContentLength != "" { 497 | w.Header().Add("Content-Length", tc.ContentLength) 498 | } 499 | _ = "breakpoint" 500 | rangeReq, done := checkETag(w, r, tc.ModTime) 501 | assert.Equal(tc.RangeReq, rangeReq, fmt.Sprintf("test case #%d", i)) 502 | assert.Equal(tc.Done, done, fmt.Sprintf("test case #%d", i)) 503 | if done { 504 | assert.Equal("", w.Header().Get("Content-Length")) 505 | assert.Equal("", w.Header().Get("Content-Type")) 506 | } else { 507 | assert.Equal(tc.ContentLength, w.Header().Get("Content-Length")) 508 | assert.Equal(tc.ContentType, w.Header().Get("Content-Type")) 509 | } 510 | } 511 | } 512 | 513 | func TestCheckLastModified(t *testing.T) { 514 | assert := assert.New(t) 515 | 516 | testCases := []struct { 517 | ModTime time.Time 518 | IfModifiedSince string 519 | ContentType string 520 | ContentLength string 521 | LastModified string 522 | Status int 523 | Done bool 524 | }{ 525 | { 526 | ModTime: time.Date(2020, 8, 1, 15, 3, 41, 0, time.UTC), 527 | IfModifiedSince: "Sat, 01 Aug 2020 15:03:41 GMT", 528 | ContentType: "text/html", 529 | ContentLength: "3000", 530 | Status: http.StatusNotModified, 531 | Done: true, 532 | }, 533 | { 534 | ModTime: time.Date(2020, 8, 1, 15, 3, 41, 0, time.UTC), 535 | IfModifiedSince: "Sat, 01 Aug 2020 15:03:40 GMT", 536 | ContentType: "text/html", 537 | ContentLength: "3000", 538 | LastModified: "Sat, 01 Aug 2020 15:03:41 GMT", 539 | Status: http.StatusOK, 540 | Done: false, 541 | }, 542 | { 543 | ModTime: time.Time{}, 544 | IfModifiedSince: "Sat, 01 Aug 2020 15:03:40 GMT", 545 | ContentType: "text/html", 546 | ContentLength: "3000", 547 | Status: http.StatusOK, 548 | Done: false, 549 | }, 550 | { 551 | ModTime: time.Unix(0, 0), 552 | IfModifiedSince: "Sat, 01 Aug 2020 15:03:40 GMT", 553 | ContentType: "text/html", 554 | ContentLength: "3000", 555 | Status: http.StatusOK, 556 | Done: false, 557 | }, 558 | } 559 | 560 | for i, tc := range testCases { 561 | r := &http.Request{Header: http.Header{}} 562 | w := NewTestResponseWriter() 563 | if tc.IfModifiedSince != "" { 564 | r.Header.Set("If-Modified-Since", tc.IfModifiedSince) 565 | } 566 | if tc.ContentType != "" { 567 | w.Header().Set("Content-Type", tc.ContentType) 568 | } 569 | if tc.ContentLength != "" { 570 | w.Header().Set("Content-Length", tc.ContentLength) 571 | } 572 | done := checkLastModified(w, r, tc.ModTime) 573 | failText := fmt.Sprintf("test case #%d", i) 574 | assert.Equal(tc.Done, done, failText) 575 | assert.Equal(tc.Status, w.status, failText) 576 | if tc.LastModified != "" { 577 | assert.Equal(tc.LastModified, w.Header().Get("Last-Modified"), failText) 578 | } 579 | if done { 580 | assert.Equal("", w.Header().Get("Content-Type")) 581 | assert.Equal("", w.Header().Get("Content-Length")) 582 | } else { 583 | assert.Equal(tc.ContentType, w.Header().Get("Content-Type")) 584 | assert.Equal(tc.ContentLength, w.Header().Get("Content-Length")) 585 | } 586 | } 587 | } 588 | 589 | func getMimeType(ext string) string { 590 | mimeType := mime.TypeByExtension(ext) 591 | if mimeType == "" { 592 | mimeType = "application/octet-stream" 593 | } 594 | return mimeType 595 | } 596 | -------------------------------------------------------------------------------- /file_system.go: -------------------------------------------------------------------------------- 1 | package zipfs 2 | 3 | import ( 4 | "archive/zip" 5 | "errors" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | "os" 10 | "path" 11 | "sort" 12 | "strings" 13 | "sync" 14 | "time" 15 | ) 16 | 17 | var ( 18 | errFileClosed = errors.New("file closed") 19 | errFileSystemClosed = errors.New("filesystem closed") 20 | errNotDirectory = errors.New("not a directory") 21 | errDirectory = errors.New("is a directory") 22 | ) 23 | 24 | // FileSystem is a file system based on a ZIP file. 25 | // It implements the http.FileSystem interface. 26 | type FileSystem struct { 27 | readerAt io.ReaderAt 28 | closer io.Closer 29 | reader *zip.Reader 30 | fileInfos fileInfoMap 31 | } 32 | 33 | // New will open the Zip file specified by name and 34 | // return a new FileSystem based on that Zip file. 35 | func New(name string) (*FileSystem, error) { 36 | file, err := os.Open(name) 37 | if err != nil { 38 | return nil, err 39 | } 40 | fi, err := file.Stat() 41 | if err != nil { 42 | return nil, err 43 | } 44 | return NewFromReaderAt(file, fi.Size(), file) 45 | } 46 | 47 | // NewFromReaderAt will open the Zip file accessible by readerAt with the given size. 48 | // The closer, if not nil, will be called when the file system is closed. 49 | func NewFromReaderAt(readerAt io.ReaderAt, size int64, closer io.Closer) (*FileSystem, error) { 50 | zipReader, err := zip.NewReader(readerAt, size) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | // Separate the file into an io.ReaderAt and an io.Closer. 56 | // Earlier versions of the code allowed for opening a filesystem 57 | // just with an io.ReaderAt. Not also that thw zip.Reader is 58 | // not actually used outside of this function so it probably 59 | // does not need to be in the FileSystem structure. Keeping it 60 | // there for now but may remove it in future. 61 | fs := &FileSystem{ 62 | closer: closer, 63 | readerAt: readerAt, 64 | reader: zipReader, 65 | fileInfos: fileInfoMap{}, 66 | } 67 | 68 | // Build a map of file paths to speed lookup. 69 | // Note that this assumes that there are not a very 70 | // large number of files in the ZIP file. 71 | // 72 | // Because we iterate through the map it seems reasonable 73 | // to attach each fileInfo to it's parent directory. Once again, 74 | // reasonable if the ZIP file does not contain a very large number 75 | // of entries. 76 | for _, zf := range fs.reader.File { 77 | fi := fs.fileInfos.FindOrCreate(zf.Name) 78 | fi.zipFile = zf 79 | fiParent := fs.fileInfos.FindOrCreateParent(zf.Name) 80 | fiParent.fileInfos = append(fiParent.fileInfos, fi) 81 | } 82 | 83 | // Sort all of the list of fileInfos in each directory. 84 | for _, fi := range fs.fileInfos { 85 | if len(fi.fileInfos) > 1 { 86 | sort.Sort(fi.fileInfos) 87 | } 88 | } 89 | 90 | return fs, nil 91 | } 92 | 93 | // Open implements the http.FileSystem interface. 94 | // A http.File is returned, which can be served by 95 | // the http.FileServer implementation. 96 | func (fs *FileSystem) Open(name string) (http.File, error) { 97 | fi, err := fs.openFileInfo(name) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | return fi.openReader(name), nil 103 | } 104 | 105 | // Close closes the file system's underlying ZIP file and 106 | // releases all memory allocated to internal data structures. 107 | func (fs *FileSystem) Close() error { 108 | fs.reader = nil 109 | fs.readerAt = nil 110 | var err error 111 | if fs.closer != nil { 112 | err = fs.closer.Close() 113 | fs.closer = nil 114 | } 115 | fs.fileInfos = nil 116 | return err 117 | } 118 | 119 | type fileInfoList []*fileInfo 120 | 121 | func (fl fileInfoList) Len() int { 122 | return len(fl) 123 | } 124 | 125 | func (fl fileInfoList) Less(i, j int) bool { 126 | name1 := fl[i].Name() 127 | name2 := fl[j].Name() 128 | return name1 < name2 129 | } 130 | 131 | func (fl fileInfoList) Swap(i, j int) { 132 | fi := fl[i] 133 | fl[i] = fl[j] 134 | fl[j] = fi 135 | } 136 | 137 | func (fs *FileSystem) openFileInfo(name string) (*fileInfo, error) { 138 | if fs.readerAt == nil { 139 | return nil, errFileSystemClosed 140 | } 141 | name = path.Clean(name) 142 | trimmedName := strings.TrimLeft(name, "/") 143 | fi := fs.fileInfos[trimmedName] 144 | if fi == nil { 145 | return nil, &os.PathError{Op: "Open", Path: name, Err: os.ErrNotExist} 146 | } 147 | 148 | return fi, nil 149 | } 150 | 151 | // fileMap keeps track of fileInfos 152 | type fileInfoMap map[string]*fileInfo 153 | 154 | func (fm fileInfoMap) FindOrCreate(name string) *fileInfo { 155 | strippedName := strings.TrimRight(name, "/") 156 | fi := fm[name] 157 | if fi == nil { 158 | fi = &fileInfo{ 159 | name: name, 160 | } 161 | fm[name] = fi 162 | if strippedName != name { 163 | // directories get two entries: with and without trailing slash 164 | fm[strippedName] = fi 165 | } 166 | } 167 | return fi 168 | } 169 | 170 | func (fm fileInfoMap) FindOrCreateParent(name string) *fileInfo { 171 | strippedName := strings.TrimRight(name, "/") 172 | dirName := path.Dir(strippedName) 173 | if dirName == "." { 174 | dirName = "/" 175 | } else if !strings.HasSuffix(dirName, "/") { 176 | dirName = dirName + "/" 177 | } 178 | return fm.FindOrCreate(dirName) 179 | } 180 | 181 | // fileInfo implements the os.FileInfo interface. 182 | type fileInfo struct { 183 | name string 184 | fs *FileSystem 185 | zipFile *zip.File 186 | fileInfos fileInfoList 187 | tempPath string 188 | mutex sync.Mutex 189 | } 190 | 191 | func (fi *fileInfo) Name() string { 192 | return path.Base(fi.name) 193 | } 194 | 195 | func (fi *fileInfo) Size() int64 { 196 | if fi.zipFile == nil { 197 | return 0 198 | } 199 | if fi.zipFile.UncompressedSize64 == 0 { 200 | return int64(fi.zipFile.UncompressedSize) 201 | } 202 | return int64(fi.zipFile.UncompressedSize64) 203 | } 204 | 205 | func (fi *fileInfo) Mode() os.FileMode { 206 | if fi.zipFile == nil || fi.IsDir() { 207 | return 0555 | os.ModeDir 208 | } 209 | return 0444 210 | } 211 | 212 | var dirTime = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) 213 | 214 | func (fi *fileInfo) ModTime() time.Time { 215 | if fi.zipFile == nil { 216 | return dirTime 217 | } 218 | return fi.zipFile.ModTime() 219 | } 220 | 221 | func (fi *fileInfo) IsDir() bool { 222 | if fi.zipFile == nil { 223 | return true 224 | } 225 | return fi.zipFile.Mode().IsDir() 226 | } 227 | 228 | func (fi *fileInfo) Sys() interface{} { 229 | return fi.zipFile 230 | } 231 | 232 | func (fi *fileInfo) openReader(name string) *fileReader { 233 | return &fileReader{ 234 | fileInfo: fi, 235 | name: name, 236 | } 237 | } 238 | 239 | func (fi *fileInfo) readdir() ([]os.FileInfo, error) { 240 | if !fi.Mode().IsDir() { 241 | return nil, errNotDirectory 242 | } 243 | 244 | v := make([]os.FileInfo, len(fi.fileInfos)) 245 | for i, fi := range fi.fileInfos { 246 | v[i] = fi 247 | } 248 | return v, nil 249 | } 250 | 251 | type fileReader struct { 252 | name string // the name used to open 253 | fileInfo *fileInfo 254 | reader io.ReadCloser 255 | file *os.File 256 | closed bool 257 | readdir []os.FileInfo 258 | } 259 | 260 | func (f *fileReader) Close() error { 261 | var errs []error 262 | if f.reader != nil { 263 | err := f.reader.Close() 264 | errs = append(errs, err) 265 | } 266 | var tempFile string 267 | if f.file != nil { 268 | tempFile = f.file.Name() 269 | err := f.file.Close() 270 | errs = append(errs, err) 271 | } 272 | if tempFile != "" { 273 | err := os.Remove(tempFile) 274 | errs = append(errs, err) 275 | } 276 | 277 | f.closed = true 278 | 279 | for _, err := range errs { 280 | if err != nil { 281 | return f.pathError("Close", err) 282 | } 283 | } 284 | return nil 285 | } 286 | 287 | func (f *fileReader) Read(p []byte) (n int, err error) { 288 | if f.closed { 289 | return 0, f.pathError("Read", errFileClosed) 290 | } 291 | if f.file != nil { 292 | return f.file.Read(p) 293 | } 294 | if f.reader == nil { 295 | f.reader, err = f.fileInfo.zipFile.Open() 296 | if err != nil { 297 | return 0, err 298 | } 299 | } 300 | return f.reader.Read(p) 301 | } 302 | 303 | func (f *fileReader) Seek(offset int64, whence int) (int64, error) { 304 | if f.closed { 305 | return 0, f.pathError("Seek", errFileClosed) 306 | } 307 | 308 | // The reader cannot seek, so close it. 309 | if f.reader != nil { 310 | if err := f.reader.Close(); err != nil { 311 | return 0, err 312 | } 313 | } 314 | 315 | // A special case for when there is no file created and the seek is 316 | // to the beginning of the file. Just open (or re-open) the reader 317 | // at the beginning of the file. 318 | if f.file == nil && offset == 0 && whence == 0 { 319 | var err error 320 | f.reader, err = f.fileInfo.zipFile.Open() 321 | return 0, err 322 | } 323 | 324 | if err := f.createTempFile(); err != nil { 325 | return 0, err 326 | } 327 | 328 | return f.file.Seek(offset, whence) 329 | } 330 | 331 | func (f *fileReader) Readdir(count int) ([]os.FileInfo, error) { 332 | var err error 333 | var osFileInfos []os.FileInfo 334 | 335 | if count > 0 { 336 | if f.readdir == nil { 337 | f.readdir, err = f.fileInfo.readdir() 338 | if err != nil { 339 | return nil, f.pathError("Readdir", err) 340 | } 341 | } 342 | if len(f.readdir) >= count { 343 | osFileInfos = f.readdir[0:count] 344 | f.readdir = f.readdir[count:] 345 | } else { 346 | osFileInfos = f.readdir 347 | f.readdir = nil 348 | err = io.EOF 349 | } 350 | } else { 351 | osFileInfos, err = f.fileInfo.readdir() 352 | if err != nil { 353 | return nil, f.pathError("Readdir", err) 354 | } 355 | } 356 | 357 | return osFileInfos, err 358 | } 359 | 360 | func (f *fileReader) Stat() (os.FileInfo, error) { 361 | return f.fileInfo, nil 362 | } 363 | 364 | func (f *fileReader) createTempFile() error { 365 | if f.reader != nil { 366 | if err := f.reader.Close(); err != nil { 367 | return err 368 | } 369 | f.reader = nil 370 | } 371 | if f.file == nil { 372 | // Open a file that contains the contents of the zip file. 373 | osFile, err := createTempFile(f.fileInfo.zipFile) 374 | if err != nil { 375 | return err 376 | } 377 | 378 | f.file = osFile 379 | } 380 | return nil 381 | } 382 | 383 | func (f *fileReader) pathError(op string, err error) error { 384 | return &os.PathError{ 385 | Op: op, 386 | Path: f.name, 387 | Err: err, 388 | } 389 | } 390 | 391 | // createTempFile creates a temporary file with the contents of the 392 | // zip file. Used to implement io.Seeker interface. 393 | func createTempFile(f *zip.File) (*os.File, error) { 394 | reader, err := f.Open() 395 | if err != nil { 396 | return nil, err 397 | } 398 | defer reader.Close() 399 | 400 | tempFile, err := ioutil.TempFile("", "zipfs") 401 | if err != nil { 402 | return nil, err 403 | } 404 | 405 | _, err = io.Copy(tempFile, reader) 406 | if err != nil { 407 | tempFile.Close() 408 | os.Remove(tempFile.Name()) 409 | return nil, err 410 | } 411 | _, err = tempFile.Seek(0, os.SEEK_SET) 412 | if err != nil { 413 | tempFile.Close() 414 | os.Remove(tempFile.Name()) 415 | return nil, err 416 | } 417 | 418 | return tempFile, nil 419 | } 420 | -------------------------------------------------------------------------------- /file_system_test.go: -------------------------------------------------------------------------------- 1 | package zipfs 2 | 3 | import ( 4 | "archive/zip" 5 | "crypto/md5" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestFileSystem(t *testing.T) { 17 | assert := assert.New(t) 18 | require := require.New(t) 19 | 20 | fs, err := New("testdata/testdata.zip") 21 | require.NoError(err) 22 | require.NotNil(fs) 23 | 24 | f, err := fs.Open("/xxx") 25 | assert.Error(err) 26 | assert.Nil(f) 27 | 28 | f, err = fs.Open("test.html") 29 | assert.NoError(err) 30 | assert.NotNil(f) 31 | 32 | } 33 | 34 | func TestOpen(t *testing.T) { 35 | assert := assert.New(t) 36 | require := require.New(t) 37 | fs, err := New("testdata/testdata.zip") 38 | require.NoError(err) 39 | 40 | testCases := []struct { 41 | Path string 42 | Error string 43 | }{ 44 | { 45 | Path: "/does/not/exist", 46 | Error: "file does not exist", 47 | }, 48 | { 49 | Path: "/img", 50 | Error: "", 51 | }, 52 | { 53 | Path: "/img/circle.png", 54 | Error: "", 55 | }, 56 | } 57 | for _, tc := range testCases { 58 | f, err := fs.Open(tc.Path) 59 | if tc.Error == "" { 60 | assert.NoError(err) 61 | assert.NotNil(f) 62 | f.Close() 63 | 64 | // testing error after closing 65 | var buf [50]byte 66 | _, err := f.Read(buf[:]) 67 | assert.Error(err) 68 | _, err = f.Seek(20, 0) 69 | assert.Error(err) 70 | } else { 71 | assert.Error(err) 72 | assert.True(strings.Contains(err.Error(), tc.Error), err.Error()) 73 | assert.True(strings.Contains(err.Error(), tc.Path), err.Error()) 74 | } 75 | } 76 | 77 | err = fs.Close() 78 | assert.NoError(err) 79 | f, err := fs.Open("/img/circle.png") 80 | assert.Error(err) 81 | assert.Nil(f) 82 | assert.True(strings.Contains(err.Error(), "filesystem closed"), err.Error()) 83 | } 84 | 85 | func TestReaddir(t *testing.T) { 86 | assert := assert.New(t) 87 | require := require.New(t) 88 | fs, err := New("testdata/testdata.zip") 89 | require.NoError(err) 90 | 91 | testCases := []struct { 92 | Path string 93 | Count int 94 | Error string 95 | Files []string 96 | }{ 97 | { 98 | Path: "/img", 99 | Error: "", 100 | Files: []string{ 101 | "another-circle.png", 102 | "circle.png", 103 | }, 104 | }, 105 | { 106 | Path: "/", 107 | Error: "", 108 | Files: []string{ 109 | "application-23a0.js", 110 | "empty", 111 | "img", 112 | "index.html", 113 | "js", 114 | "lots-of-files", 115 | "not-a-zip-file.txt", 116 | "random.dat", 117 | "test.html", 118 | }, 119 | }, 120 | { 121 | Path: "/lots-of-files", 122 | Error: "", 123 | Files: []string{ 124 | "file-01", 125 | "file-02", 126 | "file-03", 127 | "file-04", 128 | "file-05", 129 | "file-06", 130 | "file-07", 131 | "file-08", 132 | "file-09", 133 | "file-10", 134 | "file-11", 135 | "file-12", 136 | "file-13", 137 | "file-14", 138 | "file-15", 139 | "file-16", 140 | "file-17", 141 | "file-18", 142 | "file-19", 143 | "file-20", 144 | }, 145 | }, 146 | { 147 | Path: "/img/circle.png", 148 | Error: "not a directory", 149 | }, 150 | { 151 | Path: "/img/circle.png", 152 | Error: "not a directory", 153 | Count: 2, 154 | }, 155 | } 156 | 157 | for _, tc := range testCases { 158 | f, err := fs.Open(tc.Path) 159 | require.NoError(err) 160 | require.NotNil(f) 161 | 162 | files, err := f.Readdir(tc.Count) 163 | if tc.Error == "" { 164 | assert.NoError(err) 165 | assert.NotNil(files) 166 | printError := false 167 | if len(files) != len(tc.Files) { 168 | printError = true 169 | } else { 170 | for i, file := range files { 171 | if file.Name() != tc.Files[i] { 172 | printError = true 173 | break 174 | } 175 | } 176 | } 177 | if printError { 178 | t.Log(tc.Path, "Readdir expected:") 179 | for i, f := range tc.Files { 180 | t.Logf(" %d: %s\n", i, f) 181 | } 182 | t.Log(tc.Path, "Readdir actual:") 183 | for i, f := range files { 184 | t.Logf(" %d: %s\n", i, f.Name()) 185 | } 186 | t.Error("Readdir failed test") 187 | } 188 | } else { 189 | assert.Error(err) 190 | assert.Nil(files) 191 | assert.True(strings.Contains(err.Error(), tc.Error), err.Error()) 192 | assert.True(strings.Contains(err.Error(), tc.Path), err.Error()) 193 | } 194 | } 195 | 196 | file, err := fs.Open("/lots-of-files") 197 | require.NoError(err) 198 | for i := 0; i < 10; i++ { 199 | a, err := file.Readdir(2) 200 | require.NoError(err) 201 | assert.Equal(len(a), 2) 202 | assert.Equal(fmt.Sprintf("file-%02d", i*2+1), a[0].Name()) 203 | assert.Equal(fmt.Sprintf("file-%02d", i*2+2), a[1].Name()) 204 | } 205 | a, err := file.Readdir(2) 206 | assert.Error(err) 207 | assert.Equal(io.EOF, err) 208 | assert.Equal(0, len(a)) 209 | } 210 | 211 | // TestFileInfo tests the os.FileInfo associated with the http.File 212 | func TestFileInfo(t *testing.T) { 213 | require := require.New(t) 214 | assert := assert.New(t) 215 | fs, err := New("testdata/testdata.zip") 216 | require.NoError(err) 217 | 218 | testCases := []struct { 219 | Path string 220 | Name string 221 | Size int64 222 | Mode os.FileMode 223 | IsDir bool 224 | HasZipFile bool 225 | }{ 226 | // Don't use any text files here because the sizes 227 | // are different between Windows and Unix-like OSs. 228 | { 229 | Path: "/img/circle.png", 230 | Name: "circle.png", 231 | Size: 5973, 232 | Mode: 0444, 233 | IsDir: false, 234 | HasZipFile: true, 235 | }, 236 | { 237 | Path: "/img/", 238 | Name: "img", 239 | Size: 0, 240 | Mode: os.ModeDir | 0555, 241 | IsDir: true, 242 | HasZipFile: true, 243 | }, 244 | { 245 | Path: "/", 246 | Name: "/", 247 | Size: 0, 248 | Mode: os.ModeDir | 0555, 249 | IsDir: true, 250 | HasZipFile: true, 251 | }, 252 | } 253 | 254 | for _, tc := range testCases { 255 | file, err := fs.Open(tc.Path) 256 | require.NoError(err) 257 | fi, err := file.Stat() 258 | require.NoError(err) 259 | assert.Equal(tc.Name, fi.Name()) 260 | assert.Equal(tc.Size, fi.Size()) 261 | assert.Equal(tc.Mode, fi.Mode()) 262 | assert.Equal(tc.IsDir, fi.IsDir()) 263 | _, hasZipFile := fi.Sys().(*zip.File) 264 | assert.Equal(tc.HasZipFile, hasZipFile, fi.Name()) 265 | assert.False(fi.ModTime().IsZero()) 266 | } 267 | } 268 | 269 | // TestFile tests the file reading capabilities. 270 | func TestFile(t *testing.T) { 271 | require := require.New(t) 272 | assert := assert.New(t) 273 | fs, err := New("testdata/testdata.zip") 274 | require.NoError(err) 275 | 276 | testCases := []struct { 277 | Path string 278 | Size int 279 | MD5 string 280 | }{ 281 | { 282 | Path: "/random.dat", 283 | Size: 10000, 284 | MD5: "3c9fe0521cabb2ab38484cd1c024a61d", 285 | }, 286 | { 287 | Path: "/img/circle.png", 288 | Size: 5973, 289 | MD5: "05e3048db45e71749e06658ccfc0753b", 290 | }, 291 | } 292 | 293 | calcMD5 := func(r io.ReadSeeker, size int, seek bool) string { 294 | if seek { 295 | n, err := r.Seek(0, 0) 296 | require.NoError(err) 297 | require.Equal(int64(0), n) 298 | } 299 | buf := make([]byte, size) 300 | n, err := io.ReadFull(r, buf) 301 | require.NoError(err) 302 | require.Equal(size, n) 303 | md5Text := fmt.Sprintf("%x", md5.Sum(buf)) 304 | n, err = r.Read(buf) 305 | require.Error(err) 306 | require.Equal(io.EOF, err) 307 | require.Equal(0, n) 308 | return md5Text 309 | } 310 | 311 | for _, tc := range testCases { 312 | file, err := fs.Open(tc.Path) 313 | assert.NoError(err) 314 | assert.Equal(tc.MD5, calcMD5(file, tc.Size, false)) 315 | 316 | // seek back to the beginning, should not have 317 | // to create a temporary file 318 | nseek, err := file.Seek(0, 0) 319 | assert.NoError(err) 320 | assert.Equal(int64(0), nseek) 321 | assert.Equal(tc.MD5, calcMD5(file, tc.Size, true)) 322 | 323 | nSeek, err := file.Seek(int64(tc.Size/2), 0) 324 | assert.NoError(err) 325 | assert.Equal(int64(tc.Size/2), nSeek) 326 | assert.Equal(tc.MD5, calcMD5(file, tc.Size, true)) 327 | 328 | file.Close() 329 | } 330 | } 331 | 332 | func TestNewFromReaderAt_NilCloser(t *testing.T) { 333 | require := require.New(t) 334 | file, err := os.Open("testdata/testdata.zip") 335 | require.NoError(err) 336 | defer file.Close() 337 | size, err := file.Seek(0, io.SeekEnd) 338 | require.NoError(err) 339 | fs, err := NewFromReaderAt(file, size, nil) 340 | require.NotNil(fs) 341 | err = fs.Close() 342 | require.NoError(err) 343 | 344 | // should be able to seek file to the beginning 345 | // because it has not been closed 346 | _, err = file.Seek(0, io.SeekStart) 347 | require.NoError(err) 348 | } 349 | 350 | func TestNewFromReaderAt_WithCloser(t *testing.T) { 351 | require := require.New(t) 352 | file, err := os.Open("testdata/testdata.zip") 353 | require.NoError(err) 354 | defer file.Close() 355 | size, err := file.Seek(0, io.SeekEnd) 356 | require.NoError(err) 357 | fs, err := NewFromReaderAt(file, size, file) 358 | require.NotNil(fs) 359 | err = fs.Close() 360 | require.NoError(err) 361 | 362 | // should not be able to seek file to the beginning 363 | // because it has been closed 364 | _, err = file.Seek(0, io.SeekStart) 365 | require.Error(err) 366 | } 367 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/spkg/zipfs 2 | 3 | go 1.12 4 | 5 | require github.com/stretchr/testify v1.3.0 6 | -------------------------------------------------------------------------------- /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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 6 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 7 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 8 | -------------------------------------------------------------------------------- /testdata/img/another-circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spkg/zipfs/4e9dd8712614888ca649db53e39fc0f7b1dd6288/testdata/img/another-circle.png -------------------------------------------------------------------------------- /testdata/img/circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spkg/zipfs/4e9dd8712614888ca649db53e39fc0f7b1dd6288/testdata/img/circle.png -------------------------------------------------------------------------------- /testdata/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | This is a test 5 | 6 | 7 |

This is a test

8 | 9 | -------------------------------------------------------------------------------- /testdata/js/application-23a0.js: -------------------------------------------------------------------------------- 1 | (function($, window, undefined) { 2 | // this is just a test 3 | })(jQuery, window); 4 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-01: -------------------------------------------------------------------------------- 1 | 01\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-02: -------------------------------------------------------------------------------- 1 | 02\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-03: -------------------------------------------------------------------------------- 1 | 03\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-04: -------------------------------------------------------------------------------- 1 | 04\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-05: -------------------------------------------------------------------------------- 1 | 05\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-06: -------------------------------------------------------------------------------- 1 | 06\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-07: -------------------------------------------------------------------------------- 1 | 07\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-08: -------------------------------------------------------------------------------- 1 | 08\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-09: -------------------------------------------------------------------------------- 1 | 09\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-10: -------------------------------------------------------------------------------- 1 | 10\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-11: -------------------------------------------------------------------------------- 1 | 11\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-12: -------------------------------------------------------------------------------- 1 | 12\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-13: -------------------------------------------------------------------------------- 1 | 13\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-14: -------------------------------------------------------------------------------- 1 | 14\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-15: -------------------------------------------------------------------------------- 1 | 15\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-16: -------------------------------------------------------------------------------- 1 | 16\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-17: -------------------------------------------------------------------------------- 1 | 17\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-18: -------------------------------------------------------------------------------- 1 | 18\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-19: -------------------------------------------------------------------------------- 1 | 19\n 2 | -------------------------------------------------------------------------------- /testdata/lots-of-files/file-20: -------------------------------------------------------------------------------- 1 | 20\n 2 | -------------------------------------------------------------------------------- /testdata/not-a-zip-file.txt: -------------------------------------------------------------------------------- 1 | This is not a ZIP file. 2 | -------------------------------------------------------------------------------- /testdata/random.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spkg/zipfs/4e9dd8712614888ca649db53e39fc0f7b1dd6288/testdata/random.dat -------------------------------------------------------------------------------- /testdata/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | This is another test 5 | 6 | 7 |

This is another test

8 | 9 | -------------------------------------------------------------------------------- /testdata/testdata.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spkg/zipfs/4e9dd8712614888ca649db53e39fc0f7b1dd6288/testdata/testdata.zip -------------------------------------------------------------------------------- /zipfs.go: -------------------------------------------------------------------------------- 1 | // Package zipfs provides an implementation of the net/http.FileSystem 2 | // interface based on the contents of a ZIP file. It also provides 3 | // the FileServer function, which returns a net/http.Handler that 4 | // serves static files from a ZIP file. This HTTP handler exploits 5 | // the fact that most files are stored in a ZIP file using the 6 | // deflate compression algorithm, and that most HTTP user agents will 7 | // accept deflate as a content-encoding. When possible the HTTP 8 | // handler will send the compressed file contents back to the 9 | // user agent without having to decompress the ZIP file contents. 10 | package zipfs 11 | --------------------------------------------------------------------------------