266 lines
6.1 KiB
Go
266 lines
6.1 KiB
Go
// 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)
|
|
}
|
|
}
|