// 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) } }