Merge branch '9272-test-races'
[arvados.git] / services / keep-web / handler.go
index 21d58b7e029cae8e1a82b4737831e88addad9847..6f5f66ae0ef1bf57979f04189fe4d110818b1bd6 100644 (file)
@@ -9,8 +9,9 @@ import (
        "net/http"
        "net/url"
        "os"
+       "regexp"
+       "strconv"
        "strings"
-       "time"
 
        "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
        "git.curoverse.com/arvados.git/sdk/go/auth"
@@ -99,6 +100,15 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                return
        }
 
+       if r.Header.Get("Origin") != "" {
+               // Allow simple cross-origin requests without user
+               // credentials ("user credentials" as defined by CORS,
+               // i.e., cookies, HTTP authentication, and client-side
+               // SSL certificates. See
+               // http://www.w3.org/TR/cors/#user-credentials).
+               w.Header().Set("Access-Control-Allow-Origin", "*")
+       }
+
        arv := clientPool.Get()
        if arv == nil {
                statusCode, statusText = http.StatusInternalServerError, "Pool failed: "+clientPool.Err().Error()
@@ -148,7 +158,24 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                statusCode = http.StatusNotFound
                return
        }
-       if t := r.FormValue("api_token"); t != "" {
+
+       formToken := r.FormValue("api_token")
+       if formToken != "" && r.Header.Get("Origin") != "" && attachment && r.URL.Query().Get("api_token") == "" {
+               // The client provided an explicit token in the POST
+               // body. The Origin header indicates this *might* be
+               // an AJAX request, in which case redirect-with-cookie
+               // won't work: we should just serve the content in the
+               // POST response. This is safe because:
+               //
+               // * We're supplying an attachment, not inline
+               //   content, so we don't need to convert the POST to
+               //   a GET and avoid the "really resubmit form?"
+               //   problem.
+               //
+               // * The token isn't embedded in the URL, so we don't
+               //   need to worry about bookmarks and copy/paste.
+               tokens = append(tokens, formToken)
+       } else if formToken != "" {
                // The client provided an explicit token in the query
                // string, or a form in POST body. We must put the
                // token in an HttpOnly cookie, and redirect to the
@@ -178,12 +205,21 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
 
                http.SetCookie(w, &http.Cookie{
                        Name:     "arvados_api_token",
-                       Value:    auth.EncodeTokenCookie([]byte(t)),
+                       Value:    auth.EncodeTokenCookie([]byte(formToken)),
                        Path:     "/",
-                       Expires:  time.Now().AddDate(10, 0, 0),
                        HttpOnly: true,
                })
-               redir := (&url.URL{Host: r.Host, Path: r.URL.Path}).String()
+
+               // Propagate query parameters (except api_token) from
+               // the original request.
+               redirQuery := r.URL.Query()
+               redirQuery.Del("api_token")
+
+               redir := (&url.URL{
+                       Host:     r.Host,
+                       Path:     r.URL.Path,
+                       RawQuery: redirQuery.Encode(),
+               }).String()
 
                w.Header().Add("Location", redir)
                statusCode, statusText = http.StatusSeeOther, redir
@@ -284,6 +320,12 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                statusCode, statusText = http.StatusInternalServerError, err.Error()
                return
        }
+       if kc.Client != nil && kc.Client.Transport != nil {
+               // Workaround for https://dev.arvados.org/issues/9005
+               if t, ok := kc.Client.Transport.(*http.Transport); ok {
+                       defer t.CloseIdleConnections()
+               }
+       }
        rdr, err := kc.CollectionFileReader(collection, filename)
        if os.IsNotExist(err) {
                statusCode = http.StatusNotFound
@@ -294,8 +336,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,13 +350,53 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
        if rdr, ok := rdr.(keepclient.ReadCloserWithLen); ok {
                w.Header().Set("Content-Length", fmt.Sprintf("%d", rdr.Len()))
        }
-       if attachment {
-               w.Header().Set("Content-Disposition", "attachment")
-       }
 
-       w.WriteHeader(http.StatusOK)
-       _, err = io.Copy(w, rdr)
+       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 isAttachment {
+               disposition = "attachment"
+       }
+       if strings.ContainsRune(r.RequestURI, '?') {
+               // Help the UA realize that the filename is just
+               // "filename.txt", not
+               // "filename.txt?disposition=attachment".
+               //
+               // TODO(TC): Follow advice at RFC 6266 appendix D
+               disposition += "; filename=" + strconv.QuoteToASCII(filename)
+       }
+       if disposition != "inline" {
+               w.Header().Set("Content-Disposition", disposition)
+       }
+}