7965: Fail orphaned jobs still marked "running" after reboot.
[arvados.git] / services / keep-web / handler.go
index 5f546ced69f6648af5a0c4ef078c03d9fdead574..e1b23621af8f70aa214e43639140ca23ed2784c4 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"
@@ -23,27 +24,26 @@ type handler struct{}
 var (
        clientPool         = arvadosclient.MakeClientPool()
        trustAllContent    = false
-       anonymousTokens    []string
        attachmentOnlyHost = ""
 )
 
 func init() {
-       flag.BoolVar(&trustAllContent, "trust-all-content", false,
-               "Serve non-public content from a single origin. Dangerous: read docs before using!")
        flag.StringVar(&attachmentOnlyHost, "attachment-only-host", "",
                "Accept credentials, and add \"Content-Disposition: attachment\" response headers, for requests at this hostname:port. Prohibiting inline display makes it possible to serve untrusted and non-public content from a single origin, i.e., without wildcard DNS or SSL.")
+       flag.BoolVar(&trustAllContent, "trust-all-content", false,
+               "Serve non-public content from a single origin. Dangerous: read docs before using!")
 }
 
 // return a UUID or PDH if s begins with a UUID or URL-encoded PDH;
 // otherwise return "".
-func parseCollectionIdFromDNSName(s string) string {
+func parseCollectionIDFromDNSName(s string) string {
        // Strip domain.
        if i := strings.IndexRune(s, '.'); i >= 0 {
                s = s[:i]
        }
-       // Names like {uuid}--dl.example.com serve the same purpose as
-       // {uuid}.dl.example.com but can reduce cost/effort of using
-       // [additional] wildcard certificates.
+       // Names like {uuid}--collections.example.com serve the same
+       // purpose as {uuid}.collections.example.com but can reduce
+       // cost/effort of using [additional] wildcard certificates.
        if i := strings.Index(s, "--"); i >= 0 {
                s = s[:i]
        }
@@ -60,7 +60,7 @@ var urlPDHDecoder = strings.NewReplacer(" ", "+", "-", "+")
 
 // return a UUID or PDH if s is a UUID or a PDH (even if it is a PDH
 // with "+" replaced by " " or "-"); otherwise return "".
-func parseCollectionIdFromURL(s string) string {
+func parseCollectionIDFromURL(s string) string {
        if arvadosclient.UUIDMatch(s) {
                return s
        }
@@ -100,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()
@@ -109,7 +118,7 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
 
        pathParts := strings.Split(r.URL.Path[1:], "/")
 
-       var targetId string
+       var targetID string
        var targetPath []string
        var tokens []string
        var reqTokens []string
@@ -124,24 +133,24 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                attachment = true
        }
 
-       if targetId = parseCollectionIdFromDNSName(r.Host); targetId != "" {
-               // http://ID.dl.example/PATH...
+       if targetID = parseCollectionIDFromDNSName(r.Host); targetID != "" {
+               // http://ID.collections.example/PATH...
                credentialsOK = true
                targetPath = pathParts
        } else if len(pathParts) >= 2 && strings.HasPrefix(pathParts[0], "c=") {
                // /c=ID/PATH...
-               targetId = parseCollectionIdFromURL(pathParts[0][2:])
+               targetID = parseCollectionIDFromURL(pathParts[0][2:])
                targetPath = pathParts[1:]
        } else if len(pathParts) >= 3 && pathParts[0] == "collections" {
                if len(pathParts) >= 5 && pathParts[1] == "download" {
                        // /collections/download/ID/TOKEN/PATH...
-                       targetId = pathParts[2]
+                       targetID = pathParts[2]
                        tokens = []string{pathParts[3]}
                        targetPath = pathParts[4:]
                        pathToken = true
                } else {
                        // /collections/ID/PATH...
-                       targetId = pathParts[1]
+                       targetID = pathParts[1]
                        tokens = anonymousTokens
                        targetPath = pathParts[2:]
                }
@@ -149,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
@@ -179,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
@@ -216,10 +251,11 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
 
        if len(targetPath) > 0 && targetPath[0] == "_" {
                // If a collection has a directory called "t=foo" or
-               // "_", it can be served at //dl.example/_/t=foo/ or
-               // //dl.example/_/_/ respectively: //dl.example/t=foo/
-               // won't work because t=foo will be interpreted as a
-               // token "foo".
+               // "_", it can be served at
+               // //collections.example/_/t=foo/ or
+               // //collections.example/_/_/ respectively:
+               // //collections.example/t=foo/ won't work because
+               // t=foo will be interpreted as a token "foo".
                targetPath = targetPath[1:]
        }
 
@@ -227,7 +263,7 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
        collection := make(map[string]interface{})
        found := false
        for _, arv.ApiToken = range tokens {
-               err := arv.Get("collections", targetId, nil, &collection)
+               err := arv.Get("collections", targetID, nil, &collection)
                if err == nil {
                        // Success
                        found = true
@@ -273,7 +309,7 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                // someone trying (anonymously) to download public
                // data that has been deleted.  Allow a referrer to
                // provide this context somehow?
-               w.Header().Add("WWW-Authenticate", "Basic realm=\"dl\"")
+               w.Header().Add("WWW-Authenticate", "Basic realm=\"collections\"")
                statusCode = http.StatusUnauthorized
                return
        }
@@ -294,8 +330,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.
@@ -303,14 +341,56 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                        w.Header().Set("Content-Type", t)
                }
        }
-       w.Header().Set("Content-Length", fmt.Sprintf("%d", rdr.Len()))
-       if attachment {
-               w.Header().Set("Content-Disposition", "attachment")
+       if rdr, ok := rdr.(keepclient.ReadCloserWithLen); ok {
+               w.Header().Set("Content-Length", fmt.Sprintf("%d", rdr.Len()))
        }
 
-       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)
+       }
+}