5824: Support partial content with Range header (only if start==0).
authorTom Clegg <tom@curoverse.com>
Sun, 8 Nov 2015 11:39:05 +0000 (06:39 -0500)
committerTom Clegg <tom@curoverse.com>
Mon, 9 Nov 2015 08:29:21 +0000 (03:29 -0500)
services/keep-web/handler.go
services/keep-web/handler_test.go

index e8678fa761bc2f6292ebbd92ec11555ccefe910b..962c5e13387a1d392e44bc2a716c51f3c856123c 100644 (file)
@@ -9,6 +9,7 @@ import (
        "net/http"
        "net/url"
        "os"
+       "regexp"
        "strconv"
        "strings"
 
@@ -293,8 +294,10 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
        }
        defer rdr.Close()
 
-       // One or both of these can be -1 if not found:
        basenamePos := strings.LastIndex(filename, "/")
+       if basenamePos < 0 {
+               basenamePos = 0
+       }
        extPos := strings.LastIndex(filename, ".")
        if extPos > basenamePos {
                // Now extPos is safely >= 0.
@@ -306,8 +309,41 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                w.Header().Set("Content-Length", fmt.Sprintf("%d", rdr.Len()))
        }
 
+       applyContentDispositionHdr(w, r, filename[basenamePos:], attachment)
+       rangeRdr, statusCode := applyRangeHdr(w, r, rdr)
+
+       w.WriteHeader(statusCode)
+       _, err = io.Copy(w, rangeRdr)
+       if err != nil {
+               statusCode, statusText = http.StatusBadGateway, err.Error()
+       }
+}
+
+var rangeRe = regexp.MustCompile(`^bytes=0-([0-9]*)$`)
+
+func applyRangeHdr(w http.ResponseWriter, r *http.Request, rdr keepclient.ReadCloserWithLen) (io.Reader, int) {
+       w.Header().Set("Accept-Ranges", "bytes")
+       hdr := r.Header.Get("Range")
+       fields := rangeRe.FindStringSubmatch(hdr)
+       if fields == nil {
+               return rdr, http.StatusOK
+       }
+       rangeEnd, err := strconv.ParseInt(fields[1], 10, 64)
+       if err != nil {
+               // Empty or too big for int64 == send entire content
+               return rdr, http.StatusOK
+       }
+       if uint64(rangeEnd) >= rdr.Len() {
+               return rdr, http.StatusOK
+       }
+       w.Header().Set("Content-Length", fmt.Sprintf("%d", rangeEnd+1))
+       w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", 0, rangeEnd, rdr.Len()))
+       return &io.LimitedReader{R: rdr, N: rangeEnd + 1}, http.StatusPartialContent
+}
+
+func applyContentDispositionHdr(w http.ResponseWriter, r *http.Request, filename string, isAttachment bool) {
        disposition := "inline"
-       if attachment {
+       if isAttachment {
                disposition = "attachment"
        }
        if strings.ContainsRune(r.RequestURI, '?') {
@@ -316,18 +352,9 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                // "filename.txt?disposition=attachment".
                //
                // TODO(TC): Follow advice at RFC 6266 appendix D
-               if basenamePos < 0 {
-                       basenamePos = 0
-               }
-               disposition += "; filename=" + strconv.QuoteToASCII(filename[basenamePos:])
+               disposition += "; filename=" + strconv.QuoteToASCII(filename)
        }
        if disposition != "inline" {
                w.Header().Set("Content-Disposition", disposition)
        }
-
-       w.WriteHeader(http.StatusOK)
-       _, err = io.Copy(w, rdr)
-       if err != nil {
-               statusCode, statusText = http.StatusBadGateway, err.Error()
-       }
 }
index 392de94ffb5af1399bada756ed4a8efc7f33506b..b4758e0f58ce10e2ba962f4ecbe4f124f8d9e578 100644 (file)
@@ -315,6 +315,47 @@ func (s *IntegrationSuite) TestAnonymousTokenError(c *check.C) {
        )
 }
 
+func (s *IntegrationSuite) TestRange(c *check.C) {
+       u, _ := url.Parse("http://example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt")
+       req := &http.Request{
+               Method: "GET",
+               Host:   u.Host,
+               URL:    u,
+               Header: http.Header{"Range": {"bytes=0-4"}},
+       }
+       resp := httptest.NewRecorder()
+       (&handler{}).ServeHTTP(resp, req)
+       c.Check(resp.Code, check.Equals, http.StatusPartialContent)
+       c.Check(resp.Body.String(), check.Equals, "Hello")
+       c.Check(resp.Header().Get("Content-Length"), check.Equals, "5")
+       c.Check(resp.Header().Get("Content-Range"), check.Equals, "bytes 0-4/12")
+
+       req.Header.Set("Range", "bytes=0-")
+       resp = httptest.NewRecorder()
+       (&handler{}).ServeHTTP(resp, req)
+       // 200 and 206 are both correct:
+       c.Check(resp.Code, check.Equals, http.StatusOK)
+       c.Check(resp.Body.String(), check.Equals, "Hello world\n")
+       c.Check(resp.Header().Get("Content-Length"), check.Equals, "12")
+
+       // Unsupported ranges are ignored
+       for _, hdr := range []string{
+               "bytes=5-5",  // non-zero start byte
+               "bytes=-5",   // last 5 bytes
+               "cubits=0-5", // unsupported unit
+               "bytes=0-340282366920938463463374607431768211456", // 2^128
+       } {
+               req.Header.Set("Range", hdr)
+               resp = httptest.NewRecorder()
+               (&handler{}).ServeHTTP(resp, req)
+               c.Check(resp.Code, check.Equals, http.StatusOK)
+               c.Check(resp.Body.String(), check.Equals, "Hello world\n")
+               c.Check(resp.Header().Get("Content-Length"), check.Equals, "12")
+               c.Check(resp.Header().Get("Content-Range"), check.Equals, "")
+               c.Check(resp.Header().Get("Accept-Ranges"), check.Equals, "bytes")
+       }
+}
+
 func (s *IntegrationSuite) testVhostRedirectTokenToCookie(c *check.C, method, hostPath, queryString, contentType, reqBody string, expectStatus int, expectRespBody string) *httptest.ResponseRecorder {
        u, _ := url.Parse(`http://` + hostPath + queryString)
        req := &http.Request{