| // Copyright 2013 The Go Authors. All rights reserved. |
| // |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file or at |
| // https://developers.google.com/open-source/licenses/bsd. |
| |
| package httputil |
| |
| import ( |
| "bytes" |
| "crypto/sha1" |
| "errors" |
| "fmt" |
| "github.com/golang/gddo/httputil/header" |
| "io" |
| "io/ioutil" |
| "mime" |
| "net/http" |
| "os" |
| "path" |
| "path/filepath" |
| "strconv" |
| "strings" |
| "sync" |
| "time" |
| ) |
| |
| // StaticServer serves static files. |
| type StaticServer struct { |
| // Dir specifies the location of the directory containing the files to serve. |
| Dir string |
| |
| // MaxAge specifies the maximum age for the cache control and expiration |
| // headers. |
| MaxAge time.Duration |
| |
| // Error specifies the function used to generate error responses. If Error |
| // is nil, then http.Error is used to generate error responses. |
| Error Error |
| |
| // MIMETypes is a map from file extensions to MIME types. |
| MIMETypes map[string]string |
| |
| mu sync.Mutex |
| etags map[string]string |
| } |
| |
| func (ss *StaticServer) resolve(fname string) string { |
| if path.IsAbs(fname) { |
| panic("Absolute path not allowed when creating a StaticServer handler") |
| } |
| dir := ss.Dir |
| if dir == "" { |
| dir = "." |
| } |
| fname = filepath.FromSlash(fname) |
| return filepath.Join(dir, fname) |
| } |
| |
| func (ss *StaticServer) mimeType(fname string) string { |
| ext := path.Ext(fname) |
| var mimeType string |
| if ss.MIMETypes != nil { |
| mimeType = ss.MIMETypes[ext] |
| } |
| if mimeType == "" { |
| mimeType = mime.TypeByExtension(ext) |
| } |
| if mimeType == "" { |
| mimeType = "application/octet-stream" |
| } |
| return mimeType |
| } |
| |
| func (ss *StaticServer) openFile(fname string) (io.ReadCloser, int64, string, error) { |
| f, err := os.Open(fname) |
| if err != nil { |
| return nil, 0, "", err |
| } |
| fi, err := f.Stat() |
| if err != nil { |
| f.Close() |
| return nil, 0, "", err |
| } |
| const modeType = os.ModeDir | os.ModeSymlink | os.ModeNamedPipe | os.ModeSocket | os.ModeDevice |
| if fi.Mode()&modeType != 0 { |
| f.Close() |
| return nil, 0, "", errors.New("not a regular file") |
| } |
| return f, fi.Size(), ss.mimeType(fname), nil |
| } |
| |
| // FileHandler returns a handler that serves a single file. The file is |
| // specified by a slash separated path relative to the static server's Dir |
| // field. |
| func (ss *StaticServer) FileHandler(fileName string) http.Handler { |
| id := fileName |
| fileName = ss.resolve(fileName) |
| return &staticHandler{ |
| ss: ss, |
| id: func(_ string) string { return id }, |
| open: func(_ string) (io.ReadCloser, int64, string, error) { return ss.openFile(fileName) }, |
| } |
| } |
| |
| // DirectoryHandler returns a handler that serves files from a directory tree. |
| // The directory is specified by a slash separated path relative to the static |
| // server's Dir field. |
| func (ss *StaticServer) DirectoryHandler(prefix, dirName string) http.Handler { |
| if !strings.HasSuffix(prefix, "/") { |
| prefix += "/" |
| } |
| idBase := dirName |
| dirName = ss.resolve(dirName) |
| return &staticHandler{ |
| ss: ss, |
| id: func(p string) string { |
| if !strings.HasPrefix(p, prefix) { |
| return "." |
| } |
| return path.Join(idBase, p[len(prefix):]) |
| }, |
| open: func(p string) (io.ReadCloser, int64, string, error) { |
| if !strings.HasPrefix(p, prefix) { |
| return nil, 0, "", errors.New("request url does not match directory prefix") |
| } |
| p = p[len(prefix):] |
| return ss.openFile(filepath.Join(dirName, filepath.FromSlash(p))) |
| }, |
| } |
| } |
| |
| // FilesHandler returns a handler that serves the concatentation of the |
| // specified files. The files are specified by slash separated paths relative |
| // to the static server's Dir field. |
| func (ss *StaticServer) FilesHandler(fileNames ...string) http.Handler { |
| |
| // todo: cache concatenated files on disk and serve from there. |
| |
| mimeType := ss.mimeType(fileNames[0]) |
| var buf []byte |
| var openErr error |
| |
| for _, fileName := range fileNames { |
| p, err := ioutil.ReadFile(ss.resolve(fileName)) |
| if err != nil { |
| openErr = err |
| buf = nil |
| break |
| } |
| buf = append(buf, p...) |
| } |
| |
| id := strings.Join(fileNames, " ") |
| |
| return &staticHandler{ |
| ss: ss, |
| id: func(_ string) string { return id }, |
| open: func(p string) (io.ReadCloser, int64, string, error) { |
| return ioutil.NopCloser(bytes.NewReader(buf)), int64(len(buf)), mimeType, openErr |
| }, |
| } |
| } |
| |
| type staticHandler struct { |
| id func(fname string) string |
| open func(p string) (io.ReadCloser, int64, string, error) |
| ss *StaticServer |
| } |
| |
| func (h *staticHandler) error(w http.ResponseWriter, r *http.Request, status int, err error) { |
| http.Error(w, http.StatusText(status), status) |
| } |
| |
| func (h *staticHandler) etag(p string) (string, error) { |
| id := h.id(p) |
| |
| h.ss.mu.Lock() |
| if h.ss.etags == nil { |
| h.ss.etags = make(map[string]string) |
| } |
| etag := h.ss.etags[id] |
| h.ss.mu.Unlock() |
| |
| if etag != "" { |
| return etag, nil |
| } |
| |
| // todo: if a concurrent goroutine is calculating the hash, then wait for |
| // it instead of computing it again here. |
| |
| rc, _, _, err := h.open(p) |
| if err != nil { |
| return "", err |
| } |
| |
| defer rc.Close() |
| |
| w := sha1.New() |
| _, err = io.Copy(w, rc) |
| if err != nil { |
| return "", err |
| } |
| |
| etag = fmt.Sprintf(`"%x"`, w.Sum(nil)) |
| |
| h.ss.mu.Lock() |
| h.ss.etags[id] = etag |
| h.ss.mu.Unlock() |
| |
| return etag, nil |
| } |
| |
| func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| p := path.Clean(r.URL.Path) |
| if p != r.URL.Path { |
| http.Redirect(w, r, p, 301) |
| return |
| } |
| |
| etag, err := h.etag(p) |
| if err != nil { |
| h.error(w, r, http.StatusNotFound, err) |
| return |
| } |
| |
| maxAge := h.ss.MaxAge |
| if maxAge == 0 { |
| maxAge = 24 * time.Hour |
| } |
| if r.FormValue("v") != "" { |
| maxAge = 365 * 24 * time.Hour |
| } |
| |
| cacheControl := fmt.Sprintf("public, max-age=%d", maxAge/time.Second) |
| |
| for _, e := range header.ParseList(r.Header, "If-None-Match") { |
| if e == etag { |
| w.Header().Set("Cache-Control", cacheControl) |
| w.Header().Set("Etag", etag) |
| w.WriteHeader(http.StatusNotModified) |
| return |
| } |
| } |
| |
| rc, cl, ct, err := h.open(p) |
| if err != nil { |
| h.error(w, r, http.StatusNotFound, err) |
| return |
| } |
| defer rc.Close() |
| |
| w.Header().Set("Cache-Control", cacheControl) |
| w.Header().Set("Etag", etag) |
| if ct != "" { |
| w.Header().Set("Content-Type", ct) |
| } |
| if cl != 0 { |
| w.Header().Set("Content-Length", strconv.FormatInt(cl, 10)) |
| } |
| w.WriteHeader(http.StatusOK) |
| if r.Method != "HEAD" { |
| io.Copy(w, rc) |
| } |
| } |