X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/0f644e242ef37c911ad3dc25aca8135c339de349..987457b7e545f4ad1e32c7a07c00c29c24326421:/services/keep-web/handler.go diff --git a/services/keep-web/handler.go b/services/keep-web/handler.go index 769c2f59ce..19a2040b4a 100644 --- a/services/keep-web/handler.go +++ b/services/keep-web/handler.go @@ -10,6 +10,7 @@ import ( "html" "html/template" "io" + "log" "net/http" "net/url" "os" @@ -21,14 +22,18 @@ import ( "git.curoverse.com/arvados.git/sdk/go/arvados" "git.curoverse.com/arvados.git/sdk/go/arvadosclient" "git.curoverse.com/arvados.git/sdk/go/auth" + "git.curoverse.com/arvados.git/sdk/go/health" "git.curoverse.com/arvados.git/sdk/go/httpserver" "git.curoverse.com/arvados.git/sdk/go/keepclient" + "golang.org/x/net/webdav" ) type handler struct { - Config *Config - clientPool *arvadosclient.ClientPool - setupOnce sync.Once + Config *Config + clientPool *arvadosclient.ClientPool + setupOnce sync.Once + healthHandler http.Handler + webdavLS webdav.LockSystem } // parseCollectionIDFromDNSName returns a UUID or PDH if s begins with @@ -70,18 +75,96 @@ func parseCollectionIDFromURL(s string) string { func (h *handler) setup() { h.clientPool = arvadosclient.MakeClientPool() + keepclient.RefreshServiceDiscoveryOnSIGHUP() + + h.healthHandler = &health.Handler{ + Token: h.Config.ManagementToken, + Prefix: "/_health/", + } + + // Even though we don't accept LOCK requests, every webdav + // handler must have a non-nil LockSystem. + h.webdavLS = &noLockSystem{} } func (h *handler) serveStatus(w http.ResponseWriter, r *http.Request) { status := struct { cacheStats + Version string }{ cacheStats: h.Config.Cache.Stats(), + Version: version, } json.NewEncoder(w).Encode(status) } +// updateOnSuccess wraps httpserver.ResponseWriter. If the handler +// sends an HTTP header indicating success, updateOnSuccess first +// calls the provided update func. If the update func fails, a 500 +// response is sent, and the status code and body sent by the handler +// are ignored (all response writes return the update error). +type updateOnSuccess struct { + httpserver.ResponseWriter + update func() error + sentHeader bool + err error +} + +func (uos *updateOnSuccess) Write(p []byte) (int, error) { + if uos.err != nil { + return 0, uos.err + } + if !uos.sentHeader { + uos.WriteHeader(http.StatusOK) + } + return uos.ResponseWriter.Write(p) +} + +func (uos *updateOnSuccess) WriteHeader(code int) { + if !uos.sentHeader { + uos.sentHeader = true + if code >= 200 && code < 400 { + if uos.err = uos.update(); uos.err != nil { + code := http.StatusInternalServerError + if err, ok := uos.err.(*arvados.TransactionError); ok { + code = err.StatusCode + } + log.Printf("update() changes response to HTTP %d: %T %q", code, uos.err, uos.err) + http.Error(uos.ResponseWriter, uos.err.Error(), code) + return + } + } + } + uos.ResponseWriter.WriteHeader(code) +} + +var ( + writeMethod = map[string]bool{ + "COPY": true, + "DELETE": true, + "MKCOL": true, + "MOVE": true, + "PUT": true, + "RMCOL": true, + } + webdavMethod = map[string]bool{ + "COPY": true, + "DELETE": true, + "MKCOL": true, + "MOVE": true, + "OPTIONS": true, + "PROPFIND": true, + "PUT": true, + "RMCOL": true, + } + browserMethod = map[string]bool{ + "GET": true, + "HEAD": true, + "POST": true, + } +) + // ServeHTTP implements http.Handler. func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) { h.setupOnce.Do(h.setup) @@ -110,21 +193,25 @@ 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" { + if strings.HasPrefix(r.URL.Path, "/_health/") && r.Method == "GET" { + h.healthHandler.ServeHTTP(w, r) + return + } + + if method := r.Header.Get("Access-Control-Request-Method"); method != "" && r.Method == "OPTIONS" { + if !browserMethod[method] && !webdavMethod[method] { 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-Headers", "Authorization, Content-Type, Range") + w.Header().Set("Access-Control-Allow-Methods", "COPY, DELETE, GET, MKCOL, MOVE, OPTIONS, POST, PROPFIND, PUT, RMCOL") 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" { + if !browserMethod[r.Method] && !webdavMethod[r.Method] { statusCode, statusText = http.StatusMethodNotAllowed, r.Method return } @@ -136,6 +223,7 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) { // SSL certificates. See // http://www.w3.org/TR/cors/#user-credentials). w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Expose-Headers", "Content-Range") } arv := h.clientPool.Get() @@ -208,7 +296,7 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) { // * 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 != "" { + } else if formToken != "" && browserMethod[r.Method] { // 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 @@ -315,14 +403,59 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) { return } - basename := targetPath[len(targetPath)-1] + var basename string + if len(targetPath) > 0 { + basename = targetPath[len(targetPath)-1] + } applyContentDispositionHdr(w, r, basename, attachment) - fs := collection.FileSystem(&arvados.Client{ + client := &arvados.Client{ APIHost: arv.ApiServer, AuthToken: arv.ApiToken, Insecure: arv.ApiInsecure, - }, kc) + } + fs, err := collection.FileSystem(client, kc) + if err != nil { + statusCode, statusText = http.StatusInternalServerError, err.Error() + return + } + + targetIsPDH := arvadosclient.PDHMatch(targetID) + if targetIsPDH && writeMethod[r.Method] { + statusCode, statusText = http.StatusMethodNotAllowed, errReadOnly.Error() + return + } + + if webdavMethod[r.Method] { + if writeMethod[r.Method] { + // Save the collection only if/when all + // webdav->filesystem operations succeed -- + // and send a 500 error if the modified + // collection can't be saved. + w = &updateOnSuccess{ + ResponseWriter: w, + update: func() error { + return h.Config.Cache.Update(client, *collection, fs) + }} + } + h := webdav.Handler{ + Prefix: "/" + strings.Join(pathParts[:stripParts], "/"), + FileSystem: &webdavFS{ + collfs: fs, + writing: writeMethod[r.Method], + alwaysReadEOF: r.Method == "PROPFIND", + }, + LockSystem: h.webdavLS, + Logger: func(_ *http.Request, err error) { + if err != nil { + log.Printf("error from webdav handler: %q", err) + } + }, + } + h.ServeHTTP(w, r) + return + } + openPath := "/" + strings.Join(targetPath, "/") if f, err := fs.Open(openPath); os.IsNotExist(err) { // Requested non-existent path @@ -338,7 +471,7 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) { // ".../dirname/". This way, relative links in the // listing for "dirname" can always be "fnm", never // "dirname/fnm". - h.seeOtherWithCookie(w, r, basename+"/", credentialsOK) + h.seeOtherWithCookie(w, r, r.URL.Path+"/", credentialsOK) } else if stat.IsDir() { h.serveDirectory(w, r, collection.Name, fs, openPath, stripParts) } else { @@ -499,16 +632,16 @@ func applyContentDispositionHdr(w http.ResponseWriter, r *http.Request, filename } func (h *handler) seeOtherWithCookie(w http.ResponseWriter, r *http.Request, location string, credentialsOK bool) { - if !credentialsOK { - // 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. - w.WriteHeader(http.StatusBadRequest) - return - } - if formToken := r.FormValue("api_token"); formToken != "" { + if !credentialsOK { + // 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. + w.WriteHeader(http.StatusBadRequest) + return + } + // The HttpOnly flag is necessary to prevent // JavaScript code (included in, or loaded by, a page // in the collection being served) from employing the @@ -520,7 +653,6 @@ func (h *handler) seeOtherWithCookie(w http.ResponseWriter, r *http.Request, loc // bar, and in the case of a POST request to avoid // raising warnings when the user refreshes the // resulting page. - http.SetCookie(w, &http.Cookie{ Name: "arvados_api_token", Value: auth.EncodeTokenCookie([]byte(formToken)),