mirror of
https://github.com/golang/go.git
synced 2025-12-08 06:10:04 +00:00
net/http: add ServeContent
Fixes #2039 R=r, rsc, n13m3y3r, r, rogpeppe CC=golang-dev https://golang.org/cl/5643067
This commit is contained in:
parent
59dc21584a
commit
4539d1f307
2 changed files with 178 additions and 94 deletions
|
|
@ -17,7 +17,6 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// A Dir implements http.FileSystem using the native file
|
// A Dir implements http.FileSystem using the native file
|
||||||
|
|
@ -58,32 +57,6 @@ type File interface {
|
||||||
Seek(offset int64, whence int) (int64, error)
|
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) {
|
func dirList(w ResponseWriter, f File) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
fmt.Fprintf(w, "<pre>\n")
|
fmt.Fprintf(w, "<pre>\n")
|
||||||
|
|
@ -104,6 +77,123 @@ func dirList(w ResponseWriter, f File) {
|
||||||
fmt.Fprintf(w, "</pre>\n")
|
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.
|
// name is '/'-separated, not filepath.Separator.
|
||||||
func serveFile(w ResponseWriter, r *Request, fs FileSystem, name string, redirect bool) {
|
func serveFile(w ResponseWriter, r *Request, fs FileSystem, name string, redirect bool) {
|
||||||
const indexPage = "/index.html"
|
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
|
// use contents of index.html for directory, if present
|
||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
|
if checkLastModified(w, r, d.ModTime()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
index := name + indexPage
|
index := name + indexPage
|
||||||
ff, err := fs.Open(index)
|
ff, err := fs.Open(index)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -174,60 +261,7 @@ func serveFile(w ResponseWriter, r *Request, fs FileSystem, name string, redirec
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// serve file
|
serveContent(w, r, d.Name(), d.ModTime(), d.Size(), f)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// localRedirect gives a Moved Permanently response.
|
// localRedirect gives a Moved Permanently response.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ package http_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
. "net/http"
|
. "net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
|
@ -14,6 +15,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -56,18 +58,18 @@ func TestServeFile(t *testing.T) {
|
||||||
req.Method = "GET"
|
req.Method = "GET"
|
||||||
|
|
||||||
// straight GET
|
// straight GET
|
||||||
_, body := getBody(t, req)
|
_, body := getBody(t, "straight get", req)
|
||||||
if !equal(body, file) {
|
if !equal(body, file) {
|
||||||
t.Fatalf("body mismatch: got %q, want %q", body, file)
|
t.Fatalf("body mismatch: got %q, want %q", body, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Range tests
|
// Range tests
|
||||||
for _, rt := range ServeFileRangeTests {
|
for i, rt := range ServeFileRangeTests {
|
||||||
req.Header.Set("Range", "bytes="+rt.r)
|
req.Header.Set("Range", "bytes="+rt.r)
|
||||||
if rt.r == "" {
|
if rt.r == "" {
|
||||||
req.Header["Range"] = nil
|
req.Header["Range"] = nil
|
||||||
}
|
}
|
||||||
r, body := getBody(t, req)
|
r, body := getBody(t, fmt.Sprintf("test %d", i), req)
|
||||||
if r.StatusCode != rt.code {
|
if r.StatusCode != rt.code {
|
||||||
t.Errorf("range=%q: StatusCode=%d, want %d", rt.r, 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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer res.Body.Close()
|
|
||||||
b, err := ioutil.ReadAll(res.Body)
|
b, err := ioutil.ReadAll(res.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal("reading Body:", err)
|
t.Fatal("reading Body:", err)
|
||||||
|
|
@ -306,17 +307,66 @@ func TestServeIndexHtml(t *testing.T) {
|
||||||
if s := string(b); s != want {
|
if s := string(b); s != want {
|
||||||
t.Errorf("for path %q got %q, want %q", path, s, want)
|
t.Errorf("for path %q got %q, want %q", path, s, want)
|
||||||
}
|
}
|
||||||
|
res.Body.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBody(t *testing.T, req Request) (*Response, []byte) {
|
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, testName string, req Request) (*Response, []byte) {
|
||||||
r, err := DefaultClient.Do(&req)
|
r, err := DefaultClient.Do(&req)
|
||||||
if err != nil {
|
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)
|
b, err := ioutil.ReadAll(r.Body)
|
||||||
if err != nil {
|
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
|
return r, b
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue