Merge branch '10312-nodemanager-quotas' refs #10312
[arvados.git] / services / keep-web / handler.go
index ddbc9b1553256e6b5a725bbc035361bb7dcc663b..42c37b8eebf947bea060ef2ace9a68b1fcca67ad 100644 (file)
@@ -1,15 +1,17 @@
 package main
 
 import (
-       "flag"
+       "encoding/json"
        "fmt"
        "html"
        "io"
-       "mime"
        "net/http"
        "net/url"
        "os"
+       "path"
+       "strconv"
        "strings"
+       "sync"
        "time"
 
        "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
@@ -18,25 +20,15 @@ import (
        "git.curoverse.com/arvados.git/sdk/go/keepclient"
 )
 
-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.")
+type handler struct {
+       Config     *Config
+       clientPool *arvadosclient.ClientPool
+       setupOnce  sync.Once
 }
 
-// return a UUID or PDH if s begins with a UUID or URL-encoded PDH;
-// otherwise return "".
-func parseCollectionIdFromDNSName(s string) string {
+// parseCollectionIDFromDNSName returns a UUID or PDH if s begins with
+// a UUID or URL-encoded PDH; otherwise "".
+func parseCollectionIDFromDNSName(s string) string {
        // Strip domain.
        if i := strings.IndexRune(s, '.'); i >= 0 {
                s = s[:i]
@@ -58,9 +50,10 @@ func parseCollectionIdFromDNSName(s string) string {
 
 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 {
+// parseCollectionIDFromURL returns a UUID or PDH if s is a UUID or a
+// PDH (even if it is a PDH with "+" replaced by " " or "-");
+// otherwise "".
+func parseCollectionIDFromURL(s string) string {
        if arvadosclient.UUIDMatch(s) {
                return s
        }
@@ -70,7 +63,24 @@ func parseCollectionIdFromURL(s string) string {
        return ""
 }
 
+func (h *handler) setup() {
+       h.clientPool = arvadosclient.MakeClientPool()
+       keepclient.RefreshServiceDiscoveryOnSIGHUP()
+}
+
+func (h *handler) serveStatus(w http.ResponseWriter, r *http.Request) {
+       status := struct {
+               cacheStats
+       }{
+               cacheStats: h.Config.Cache.Stats(),
+       }
+       json.NewEncoder(w).Encode(status)
+}
+
+// ServeHTTP implements http.Handler.
 func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
+       h.setupOnce.Do(h.setup)
+
        var statusCode = 0
        var statusText string
 
@@ -95,61 +105,106 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                httpserver.Log(remoteAddr, statusCode, statusText, w.WroteBodyBytes(), r.Method, r.Host, r.URL.Path, r.URL.RawQuery)
        }()
 
+       if r.Method == "OPTIONS" {
+               method := r.Header.Get("Access-Control-Request-Method")
+               if method != "GET" && method != "POST" {
+                       statusCode = http.StatusMethodNotAllowed
+                       return
+               }
+               w.Header().Set("Access-Control-Allow-Headers", "Range")
+               w.Header().Set("Access-Control-Allow-Methods", "GET, POST")
+               w.Header().Set("Access-Control-Allow-Origin", "*")
+               w.Header().Set("Access-Control-Max-Age", "86400")
+               statusCode = http.StatusOK
+               return
+       }
+
        if r.Method != "GET" && r.Method != "POST" {
                statusCode, statusText = http.StatusMethodNotAllowed, r.Method
                return
        }
 
-       arv := clientPool.Get()
+       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 := h.clientPool.Get()
        if arv == nil {
-               statusCode, statusText = http.StatusInternalServerError, "Pool failed: "+clientPool.Err().Error()
+               statusCode, statusText = http.StatusInternalServerError, "Pool failed: "+h.clientPool.Err().Error()
                return
        }
-       defer clientPool.Put(arv)
+       defer h.clientPool.Put(arv)
 
        pathParts := strings.Split(r.URL.Path[1:], "/")
 
-       var targetId string
+       var targetID string
        var targetPath []string
        var tokens []string
        var reqTokens []string
        var pathToken bool
        var attachment bool
-       credentialsOK := trustAllContent
+       credentialsOK := h.Config.TrustAllContent
 
-       if r.Host != "" && r.Host == attachmentOnlyHost {
+       if r.Host != "" && r.Host == h.Config.AttachmentOnlyHost {
                credentialsOK = true
                attachment = true
        } else if r.FormValue("disposition") == "attachment" {
                attachment = true
        }
 
-       if targetId = parseCollectionIdFromDNSName(r.Host); targetId != "" {
+       if targetID = parseCollectionIDFromDNSName(r.Host); targetID != "" {
                // http://ID.collections.example/PATH...
                credentialsOK = true
                targetPath = pathParts
+       } else if r.URL.Path == "/status.json" {
+               h.serveStatus(w, r)
+               return
        } 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 = parseCollectionIDFromURL(pathParts[2])
                        tokens = []string{pathParts[3]}
                        targetPath = pathParts[4:]
                        pathToken = true
                } else {
                        // /collections/ID/PATH...
-                       targetId = pathParts[1]
-                       tokens = anonymousTokens
+                       targetID = parseCollectionIDFromURL(pathParts[1])
+                       tokens = h.Config.AnonymousTokens
                        targetPath = pathParts[2:]
                }
-       } else {
+       }
+
+       if targetID == "" {
                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
@@ -160,7 +215,7 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                        // It is not safe to copy the provided token
                        // into a cookie unless the current vhost
                        // (origin) serves only a single collection or
-                       // we are in trustAllContent mode.
+                       // we are in TrustAllContent mode.
                        statusCode = http.StatusBadRequest
                        return
                }
@@ -179,12 +234,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
@@ -211,7 +275,7 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                if credentialsOK {
                        reqTokens = auth.NewCredentialsFromHTTPRequest(r).Tokens
                }
-               tokens = append(reqTokens, anonymousTokens...)
+               tokens = append(reqTokens, h.Config.AnonymousTokens...)
        }
 
        if len(targetPath) > 0 && targetPath[0] == "_" {
@@ -224,11 +288,17 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                targetPath = targetPath[1:]
        }
 
+       forceReload := false
+       if cc := r.Header.Get("Cache-Control"); strings.Contains(cc, "no-cache") || strings.Contains(cc, "must-revalidate") {
+               forceReload = true
+       }
+
+       var collection map[string]interface{}
        tokenResult := make(map[string]int)
-       collection := make(map[string]interface{})
        found := false
        for _, arv.ApiToken = range tokens {
-               err := arv.Get("collections", targetId, nil, &collection)
+               var err error
+               collection, err = h.Config.Cache.Get(arv, targetID, forceReload)
                if err == nil {
                        // Success
                        found = true
@@ -295,23 +365,31 @@ 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, "/")
-       extPos := strings.LastIndex(filename, ".")
-       if extPos > basenamePos {
-               // Now extPos is safely >= 0.
-               if t := mime.TypeByExtension(filename[extPos:]); t != "" {
-                       w.Header().Set("Content-Type", t)
-               }
-       }
-       w.Header().Set("Content-Length", fmt.Sprintf("%d", rdr.Len()))
-       if attachment {
-               w.Header().Set("Content-Disposition", "attachment")
-       }
+       basename := path.Base(filename)
+       applyContentDispositionHdr(w, r, basename, attachment)
 
-       w.WriteHeader(http.StatusOK)
-       _, err = io.Copy(w, rdr)
+       modstr, _ := collection["modified_at"].(string)
+       modtime, err := time.Parse(time.RFC3339Nano, modstr)
        if err != nil {
-               statusCode, statusText = http.StatusBadGateway, err.Error()
+               modtime = time.Now()
+       }
+       http.ServeContent(w, r, basename, modtime, rdr)
+}
+
+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)
        }
 }