]> Cypherpunks repositories - gostls13.git/commitdiff
net/http: add ServeContent
authorBrad Fitzpatrick <bradfitz@golang.org>
Thu, 9 Feb 2012 23:02:06 +0000 (10:02 +1100)
committerBrad Fitzpatrick <bradfitz@golang.org>
Thu, 9 Feb 2012 23:02:06 +0000 (10:02 +1100)
Fixes #2039

R=r, rsc, n13m3y3r, r, rogpeppe
CC=golang-dev
https://golang.org/cl/5643067

src/pkg/net/http/fs.go
src/pkg/net/http/fs_test.go

index 1392ca68ad680547304d9ef158e8b42209f03c92..0e192eb99c9b7fabd0f59d010f84c9de07a17b53 100644 (file)
@@ -17,7 +17,6 @@ import (
        "strconv"
        "strings"
        "time"
-       "unicode/utf8"
 )
 
 // A Dir implements http.FileSystem using the native file
@@ -58,32 +57,6 @@ type File interface {
        Seek(offset int64, whence int) (int64, error)
 }
 
-// Heuristic: b is text if it is valid UTF-8 and doesn't
-// contain any unprintable ASCII or Unicode characters.
-func isText(b []byte) bool {
-       for len(b) > 0 && utf8.FullRune(b) {
-               rune, size := utf8.DecodeRune(b)
-               if size == 1 && rune == utf8.RuneError {
-                       // decoding error
-                       return false
-               }
-               if 0x7F <= rune && rune <= 0x9F {
-                       return false
-               }
-               if rune < ' ' {
-                       switch rune {
-                       case '\n', '\r', '\t':
-                               // okay
-                       default:
-                               // binary garbage
-                               return false
-                       }
-               }
-               b = b[size:]
-       }
-       return true
-}
-
 func dirList(w ResponseWriter, f File) {
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        fmt.Fprintf(w, "<pre>\n")
@@ -104,6 +77,123 @@ func dirList(w ResponseWriter, f File) {
        fmt.Fprintf(w, "</pre>\n")
 }
 
+// ServeContent replies to the request using the content in the
+// provided ReadSeeker.  The main benefit of ServeContent over io.Copy
+// is that it handles Range requests properly, sets the MIME type, and
+// handles If-Modified-Since requests.
+//
+// If the response's Content-Type header is not set, ServeContent
+// first tries to deduce the type from name's file extension and,
+// if that fails, falls back to reading the first block of the content
+// and passing it to DetectContentType.
+// The name is otherwise unused; in particular it can be empty and is
+// never sent in the response.
+//
+// If modtime is not the zero time, ServeContent includes it in a
+// Last-Modified header in the response.  If the request includes an
+// If-Modified-Since header, ServeContent uses modtime to decide
+// whether the content needs to be sent at all.
+//
+// The content's Seek method must work: ServeContent uses
+// a seek to the end of the content to determine its size.
+//
+// Note that *os.File implements the io.ReadSeeker interface.
+func ServeContent(w ResponseWriter, req *Request, name string, modtime time.Time, content io.ReadSeeker) {
+       size, err := content.Seek(0, os.SEEK_END)
+       if err != nil {
+               Error(w, "seeker can't seek", StatusInternalServerError)
+               return
+       }
+       _, err = content.Seek(0, os.SEEK_SET)
+       if err != nil {
+               Error(w, "seeker can't seek", StatusInternalServerError)
+               return
+       }
+       serveContent(w, req, name, modtime, size, content)
+}
+
+// if name is empty, filename is unknown. (used for mime type, before sniffing)
+// if modtime.IsZero(), modtime is unknown.
+// content must be seeked to the beginning of the file.
+func serveContent(w ResponseWriter, r *Request, name string, modtime time.Time, size int64, content io.ReadSeeker) {
+       if checkLastModified(w, r, modtime) {
+               return
+       }
+
+       code := StatusOK
+
+       // If Content-Type isn't set, use the file's extension to find it.
+       if w.Header().Get("Content-Type") == "" {
+               ctype := mime.TypeByExtension(filepath.Ext(name))
+               if ctype == "" {
+                       // read a chunk to decide between utf-8 text and binary
+                       var buf [1024]byte
+                       n, _ := io.ReadFull(content, buf[:])
+                       b := buf[:n]
+                       ctype = DetectContentType(b)
+                       _, err := content.Seek(0, os.SEEK_SET) // rewind to output whole file
+                       if err != nil {
+                               Error(w, "seeker can't seek", StatusInternalServerError)
+                               return
+                       }
+               }
+               w.Header().Set("Content-Type", ctype)
+       }
+
+       // handle Content-Range header.
+       // TODO(adg): handle multiple ranges
+       sendSize := size
+       if size >= 0 {
+               ranges, err := parseRange(r.Header.Get("Range"), size)
+               if err == nil && len(ranges) > 1 {
+                       err = errors.New("multiple ranges not supported")
+               }
+               if err != nil {
+                       Error(w, err.Error(), StatusRequestedRangeNotSatisfiable)
+                       return
+               }
+               if len(ranges) == 1 {
+                       ra := ranges[0]
+                       if _, err := content.Seek(ra.start, os.SEEK_SET); err != nil {
+                               Error(w, err.Error(), StatusRequestedRangeNotSatisfiable)
+                               return
+                       }
+                       sendSize = ra.length
+                       code = StatusPartialContent
+                       w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", ra.start, ra.start+ra.length-1, size))
+               }
+
+               w.Header().Set("Accept-Ranges", "bytes")
+               if w.Header().Get("Content-Encoding") == "" {
+                       w.Header().Set("Content-Length", strconv.FormatInt(sendSize, 10))
+               }
+       }
+
+       w.WriteHeader(code)
+
+       if r.Method != "HEAD" {
+               if sendSize == -1 {
+                       io.Copy(w, content)
+               } else {
+                       io.CopyN(w, content, sendSize)
+               }
+       }
+}
+
+// modtime is the modification time of the resource to be served, or IsZero().
+// return value is whether this request is now complete.
+func checkLastModified(w ResponseWriter, r *Request, modtime time.Time) bool {
+       if modtime.IsZero() {
+               return false
+       }
+       if t, err := time.Parse(TimeFormat, r.Header.Get("If-Modified-Since")); err == nil && modtime.After(t) {
+               w.WriteHeader(StatusNotModified)
+               return true
+       }
+       w.Header().Set("Last-Modified", modtime.UTC().Format(TimeFormat))
+       return false
+}
+
 // name is '/'-separated, not filepath.Separator.
 func serveFile(w ResponseWriter, r *Request, fs FileSystem, name string, redirect bool) {
        const indexPage = "/index.html"
@@ -148,14 +238,11 @@ func serveFile(w ResponseWriter, r *Request, fs FileSystem, name string, redirec
                }
        }
 
-       if t, err := time.Parse(TimeFormat, r.Header.Get("If-Modified-Since")); err == nil && !d.ModTime().After(t) {
-               w.WriteHeader(StatusNotModified)
-               return
-       }
-       w.Header().Set("Last-Modified", d.ModTime().UTC().Format(TimeFormat))
-
        // use contents of index.html for directory, if present
        if d.IsDir() {
+               if checkLastModified(w, r, d.ModTime()) {
+                       return
+               }
                index := name + indexPage
                ff, err := fs.Open(index)
                if err == nil {
@@ -174,60 +261,7 @@ func serveFile(w ResponseWriter, r *Request, fs FileSystem, name string, redirec
                return
        }
 
-       // serve file
-       size := d.Size()
-       code := StatusOK
-
-       // If Content-Type isn't set, use the file's extension to find it.
-       if w.Header().Get("Content-Type") == "" {
-               ctype := mime.TypeByExtension(filepath.Ext(name))
-               if ctype == "" {
-                       // read a chunk to decide between utf-8 text and binary
-                       var buf [1024]byte
-                       n, _ := io.ReadFull(f, buf[:])
-                       b := buf[:n]
-                       if isText(b) {
-                               ctype = "text/plain; charset=utf-8"
-                       } else {
-                               // generic binary
-                               ctype = "application/octet-stream"
-                       }
-                       f.Seek(0, os.SEEK_SET) // rewind to output whole file
-               }
-               w.Header().Set("Content-Type", ctype)
-       }
-
-       // handle Content-Range header.
-       // TODO(adg): handle multiple ranges
-       ranges, err := parseRange(r.Header.Get("Range"), size)
-       if err == nil && len(ranges) > 1 {
-               err = errors.New("multiple ranges not supported")
-       }
-       if err != nil {
-               Error(w, err.Error(), StatusRequestedRangeNotSatisfiable)
-               return
-       }
-       if len(ranges) == 1 {
-               ra := ranges[0]
-               if _, err := f.Seek(ra.start, os.SEEK_SET); err != nil {
-                       Error(w, err.Error(), StatusRequestedRangeNotSatisfiable)
-                       return
-               }
-               size = ra.length
-               code = StatusPartialContent
-               w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", ra.start, ra.start+ra.length-1, d.Size()))
-       }
-
-       w.Header().Set("Accept-Ranges", "bytes")
-       if w.Header().Get("Content-Encoding") == "" {
-               w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
-       }
-
-       w.WriteHeader(code)
-
-       if r.Method != "HEAD" {
-               io.CopyN(w, f, size)
-       }
+       serveContent(w, r, d.Name(), d.ModTime(), d.Size(), f)
 }
 
 // localRedirect gives a Moved Permanently response.
index feea9209e6a362e1ec087a6111c38a4612575062..45059fd5f3b84e0eed90871d58c10ac0c4382e19 100644 (file)
@@ -6,6 +6,7 @@ package http_test
 
 import (
        "fmt"
+       "io"
        "io/ioutil"
        . "net/http"
        "net/http/httptest"
@@ -14,6 +15,7 @@ import (
        "path/filepath"
        "strings"
        "testing"
+       "time"
 )
 
 const (
@@ -56,18 +58,18 @@ func TestServeFile(t *testing.T) {
        req.Method = "GET"
 
        // straight GET
-       _, body := getBody(t, req)
+       _, body := getBody(t, "straight get", req)
        if !equal(body, file) {
                t.Fatalf("body mismatch: got %q, want %q", body, file)
        }
 
        // Range tests
-       for _, rt := range ServeFileRangeTests {
+       for i, rt := range ServeFileRangeTests {
                req.Header.Set("Range", "bytes="+rt.r)
                if rt.r == "" {
                        req.Header["Range"] = nil
                }
-               r, body := getBody(t, req)
+               r, body := getBody(t, fmt.Sprintf("test %d", i), req)
                if r.StatusCode != rt.code {
                        t.Errorf("range=%q: StatusCode=%d, want %d", rt.r, r.StatusCode, rt.code)
                }
@@ -298,7 +300,6 @@ func TestServeIndexHtml(t *testing.T) {
                if err != nil {
                        t.Fatal(err)
                }
-               defer res.Body.Close()
                b, err := ioutil.ReadAll(res.Body)
                if err != nil {
                        t.Fatal("reading Body:", err)
@@ -306,17 +307,66 @@ func TestServeIndexHtml(t *testing.T) {
                if s := string(b); s != want {
                        t.Errorf("for path %q got %q, want %q", path, s, want)
                }
+               res.Body.Close()
+       }
+}
+
+func TestServeContent(t *testing.T) {
+       type req struct {
+               name    string
+               modtime time.Time
+               content io.ReadSeeker
+       }
+       ch := make(chan req, 1)
+       ts := httptest.NewServer(HandlerFunc(func(w ResponseWriter, r *Request) {
+               p := <-ch
+               ServeContent(w, r, p.name, p.modtime, p.content)
+       }))
+       defer ts.Close()
+
+       css, err := os.Open("testdata/style.css")
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer css.Close()
+
+       ch <- req{"style.css", time.Time{}, css}
+       res, err := Get(ts.URL)
+       if err != nil {
+               t.Fatal(err)
+       }
+       if g, e := res.Header.Get("Content-Type"), "text/css; charset=utf-8"; g != e {
+               t.Errorf("style.css: content type = %q, want %q", g, e)
+       }
+       if g := res.Header.Get("Last-Modified"); g != "" {
+               t.Errorf("want empty Last-Modified; got %q", g)
+       }
+
+       fi, err := css.Stat()
+       if err != nil {
+               t.Fatal(err)
+       }
+       ch <- req{"style.html", fi.ModTime(), css}
+       res, err = Get(ts.URL)
+       if err != nil {
+               t.Fatal(err)
+       }
+       if g, e := res.Header.Get("Content-Type"), "text/html; charset=utf-8"; g != e {
+               t.Errorf("style.html: content type = %q, want %q", g, e)
+       }
+       if g := res.Header.Get("Last-Modified"); g == "" {
+               t.Errorf("want non-empty last-modified")
        }
 }
 
-func getBody(t *testing.T, req Request) (*Response, []byte) {
+func getBody(t *testing.T, testName string, req Request) (*Response, []byte) {
        r, err := DefaultClient.Do(&req)
        if err != nil {
-               t.Fatal(req.URL.String(), "send:", err)
+               t.Fatalf("%s: for URL %q, send error: %v", testName, req.URL.String(), err)
        }
        b, err := ioutil.ReadAll(r.Body)
        if err != nil {
-               t.Fatal("reading Body:", err)
+               t.Fatalf("%s: for URL %q, reading body: %v", testName, req.URL.String(), err)
        }
        return r, b
 }