1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
22 "git.arvados.org/arvados.git/lib/cmd"
23 "git.arvados.org/arvados.git/lib/webdavfs"
24 "git.arvados.org/arvados.git/sdk/go/arvados"
25 "git.arvados.org/arvados.git/sdk/go/arvadosclient"
26 "git.arvados.org/arvados.git/sdk/go/auth"
27 "git.arvados.org/arvados.git/sdk/go/ctxlog"
28 "git.arvados.org/arvados.git/sdk/go/httpserver"
29 "git.arvados.org/arvados.git/sdk/go/keepclient"
30 "github.com/sirupsen/logrus"
31 "golang.org/x/net/webdav"
36 Cluster *arvados.Cluster
40 var urlPDHDecoder = strings.NewReplacer(" ", "+", "-", "+")
42 var notFoundMessage = "Not Found"
43 var unauthorizedMessage = "401 Unauthorized\n\nA valid Arvados token must be provided to access this resource."
45 // parseCollectionIDFromURL returns a UUID or PDH if s is a UUID or a
46 // PDH (even if it is a PDH with "+" replaced by " " or "-");
48 func parseCollectionIDFromURL(s string) string {
49 if arvadosclient.UUIDMatch(s) {
52 if pdh := urlPDHDecoder.Replace(s); arvadosclient.PDHMatch(pdh) {
58 func (h *handler) setup() {
59 keepclient.DefaultBlockCache.MaxBlocks = h.Cluster.Collections.WebDAVCache.MaxBlockEntries
62 func (h *handler) serveStatus(w http.ResponseWriter, r *http.Request) {
63 json.NewEncoder(w).Encode(struct{ Version string }{cmd.Version.String()})
66 type errorWithHTTPStatus interface {
70 // updateOnSuccess wraps httpserver.ResponseWriter. If the handler
71 // sends an HTTP header indicating success, updateOnSuccess first
72 // calls the provided update func. If the update func fails, an error
73 // response is sent (using the error's HTTP status or 500 if none),
74 // and the status code and body sent by the handler are ignored (all
75 // response writes return the update error).
76 type updateOnSuccess struct {
77 httpserver.ResponseWriter
78 logger logrus.FieldLogger
84 func (uos *updateOnSuccess) Write(p []byte) (int, error) {
86 uos.WriteHeader(http.StatusOK)
91 return uos.ResponseWriter.Write(p)
94 func (uos *updateOnSuccess) WriteHeader(code int) {
97 if code >= 200 && code < 400 {
98 if uos.err = uos.update(); uos.err != nil {
99 code := http.StatusInternalServerError
100 if he := errorWithHTTPStatus(nil); errors.As(uos.err, &he) {
101 code = he.HTTPStatus()
103 uos.logger.WithError(uos.err).Errorf("update() returned %T error, changing response to HTTP %d", uos.err, code)
104 http.Error(uos.ResponseWriter, uos.err.Error(), code)
109 uos.ResponseWriter.WriteHeader(code)
113 corsAllowHeadersHeader = strings.Join([]string{
114 "Authorization", "Content-Type", "Range",
115 // WebDAV request headers:
116 "Depth", "Destination", "If", "Lock-Token", "Overwrite", "Timeout", "Cache-Control",
118 writeMethod = map[string]bool{
129 webdavMethod = map[string]bool{
142 browserMethod = map[string]bool{
147 // top-level dirs to serve with siteFS
148 siteFSDir = map[string]bool{
149 "": true, // root directory
155 func stripDefaultPort(host string) string {
156 // Will consider port 80 and port 443 to be the same vhost. I think that's fine.
157 u := &url.URL{Host: host}
158 if p := u.Port(); p == "80" || p == "443" {
159 return strings.ToLower(u.Hostname())
161 return strings.ToLower(host)
165 // CheckHealth implements service.Handler.
166 func (h *handler) CheckHealth() error {
170 // Done implements service.Handler.
171 func (h *handler) Done() <-chan struct{} {
175 // ServeHTTP implements http.Handler.
176 func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
177 h.setupOnce.Do(h.setup)
179 if xfp := r.Header.Get("X-Forwarded-Proto"); xfp != "" && xfp != "http" {
183 w := httpserver.WrapResponseWriter(wOrig)
185 if method := r.Header.Get("Access-Control-Request-Method"); method != "" && r.Method == "OPTIONS" {
186 if !browserMethod[method] && !webdavMethod[method] {
187 w.WriteHeader(http.StatusMethodNotAllowed)
190 w.Header().Set("Access-Control-Allow-Headers", corsAllowHeadersHeader)
191 w.Header().Set("Access-Control-Allow-Methods", "COPY, DELETE, GET, LOCK, MKCOL, MOVE, OPTIONS, POST, PROPFIND, PROPPATCH, PUT, RMCOL, UNLOCK")
192 w.Header().Set("Access-Control-Allow-Origin", "*")
193 w.Header().Set("Access-Control-Max-Age", "86400")
197 if !browserMethod[r.Method] && !webdavMethod[r.Method] {
198 w.WriteHeader(http.StatusMethodNotAllowed)
202 if r.Header.Get("Origin") != "" {
203 // Allow simple cross-origin requests without user
204 // credentials ("user credentials" as defined by CORS,
205 // i.e., cookies, HTTP authentication, and client-side
206 // SSL certificates. See
207 // http://www.w3.org/TR/cors/#user-credentials).
208 w.Header().Set("Access-Control-Allow-Origin", "*")
209 w.Header().Set("Access-Control-Expose-Headers", "Content-Range")
217 arvPath := r.URL.Path
218 if prefix := r.Header.Get("X-Webdav-Prefix"); prefix != "" {
219 // Enable a proxy (e.g., container log handler in
220 // controller) to satisfy a request for path
221 // "/foo/bar/baz.txt" using content from
222 // "//abc123-4.internal/bar/baz.txt", by adding a
223 // request header "X-Webdav-Prefix: /foo"
224 if !strings.HasPrefix(arvPath, prefix) {
225 http.Error(w, "X-Webdav-Prefix header is not a prefix of the requested path", http.StatusBadRequest)
228 arvPath = r.URL.Path[len(prefix):]
232 w.Header().Set("Vary", "X-Webdav-Prefix, "+w.Header().Get("Vary"))
233 webdavPrefix = prefix
235 pathParts := strings.Split(arvPath[1:], "/")
238 var collectionID string
240 var reqTokens []string
244 credentialsOK := h.Cluster.Collections.TrustAllContent
245 reasonNotAcceptingCredentials := ""
247 if r.Host != "" && stripDefaultPort(r.Host) == stripDefaultPort(h.Cluster.Services.WebDAVDownload.ExternalURL.Host) {
250 } else if r.FormValue("disposition") == "attachment" {
255 reasonNotAcceptingCredentials = fmt.Sprintf("vhost %q does not specify a single collection ID or match Services.WebDAVDownload.ExternalURL %q, and Collections.TrustAllContent is false",
256 r.Host, h.Cluster.Services.WebDAVDownload.ExternalURL)
259 if collectionID = arvados.CollectionIDFromDNSName(r.Host); collectionID != "" {
260 // http://ID.collections.example/PATH...
262 } else if r.URL.Path == "/status.json" {
265 } else if siteFSDir[pathParts[0]] {
267 } else if len(pathParts) >= 1 && strings.HasPrefix(pathParts[0], "c=") {
269 collectionID = parseCollectionIDFromURL(pathParts[0][2:])
271 } else if len(pathParts) >= 2 && pathParts[0] == "collections" {
272 if len(pathParts) >= 4 && pathParts[1] == "download" {
273 // /collections/download/ID/TOKEN/PATH...
274 collectionID = parseCollectionIDFromURL(pathParts[2])
275 tokens = []string{pathParts[3]}
279 // /collections/ID/PATH...
280 collectionID = parseCollectionIDFromURL(pathParts[1])
282 // This path is only meant to work for public
283 // data. Tokens provided with the request are
285 credentialsOK = false
286 reasonNotAcceptingCredentials = "the '/collections/UUID/PATH' form only works for public data"
291 if cc := r.Header.Get("Cache-Control"); strings.Contains(cc, "no-cache") || strings.Contains(cc, "must-revalidate") {
296 reqTokens = auth.CredentialsFromRequest(r).Tokens
299 formToken := r.FormValue("api_token")
300 origin := r.Header.Get("Origin")
301 cors := origin != "" && !strings.HasSuffix(origin, "://"+r.Host)
302 safeAjax := cors && (r.Method == http.MethodGet || r.Method == http.MethodHead)
303 safeAttachment := attachment && r.URL.Query().Get("api_token") == ""
305 // No token to use or redact.
306 } else if safeAjax || safeAttachment {
307 // If this is a cross-origin request, the URL won't
308 // appear in the browser's address bar, so
309 // substituting a clipboard-safe URL is pointless.
310 // Redirect-with-cookie wouldn't work anyway, because
311 // it's not safe to allow third-party use of our
314 // If we're supplying an attachment, we don't need to
315 // convert POST to GET to avoid the "really resubmit
316 // form?" problem, so provided the token isn't
317 // embedded in the URL, there's no reason to do
318 // redirect-with-cookie in this case either.
319 reqTokens = append(reqTokens, formToken)
320 } else if browserMethod[r.Method] {
321 // If this is a page view, and the client provided a
322 // token via query string or POST body, we must put
323 // the token in an HttpOnly cookie, and redirect to an
324 // equivalent URL with the query param redacted and
326 h.seeOtherWithCookie(w, r, "", credentialsOK)
330 targetPath := pathParts[stripParts:]
331 if tokens == nil && len(targetPath) > 0 && strings.HasPrefix(targetPath[0], "t=") {
332 // http://ID.example/t=TOKEN/PATH...
333 // /c=ID/t=TOKEN/PATH...
335 // This form must only be used to pass scoped tokens
336 // that give permission for a single collection. See
337 // FormValue case above.
338 tokens = []string{targetPath[0][2:]}
340 targetPath = targetPath[1:]
346 if writeMethod[r.Method] {
347 http.Error(w, webdavfs.ErrReadOnly.Error(), http.StatusMethodNotAllowed)
350 if len(reqTokens) == 0 {
351 w.Header().Add("WWW-Authenticate", "Basic realm=\"collections\"")
352 http.Error(w, unauthorizedMessage, http.StatusUnauthorized)
356 } else if collectionID == "" {
357 http.Error(w, notFoundMessage, http.StatusNotFound)
360 fsprefix = "by_id/" + collectionID + "/"
365 if h.Cluster.Users.AnonymousUserToken != "" {
366 tokens = append(tokens, h.Cluster.Users.AnonymousUserToken)
370 if len(targetPath) > 0 && targetPath[0] == "_" {
371 // If a collection has a directory called "t=foo" or
372 // "_", it can be served at
373 // //collections.example/_/t=foo/ or
374 // //collections.example/_/_/ respectively:
375 // //collections.example/t=foo/ won't work because
376 // t=foo will be interpreted as a token "foo".
377 targetPath = targetPath[1:]
381 dirOpenMode := os.O_RDONLY
382 if writeMethod[r.Method] {
383 dirOpenMode = os.O_RDWR
387 var tokenScopeProblem bool
389 var tokenUser *arvados.User
390 var sessionFS arvados.CustomFileSystem
391 var session *cachedSession
392 var collectionDir arvados.File
393 for _, token = range tokens {
394 var statusErr errorWithHTTPStatus
395 fs, sess, user, err := h.Cache.GetSession(token)
396 if errors.As(err, &statusErr) && statusErr.HTTPStatus() == http.StatusUnauthorized {
399 } else if err != nil {
400 http.Error(w, "cache error: "+err.Error(), http.StatusInternalServerError)
403 if token != h.Cluster.Users.AnonymousUserToken {
406 f, err := fs.OpenFile(fsprefix, dirOpenMode, 0)
407 if errors.As(err, &statusErr) &&
408 statusErr.HTTPStatus() == http.StatusForbidden &&
409 token != h.Cluster.Users.AnonymousUserToken {
410 // collection id is outside scope of supplied
412 tokenScopeProblem = true
414 } else if os.IsNotExist(err) {
415 // collection does not exist or is not
416 // readable using this token
418 } else if err != nil {
419 http.Error(w, err.Error(), http.StatusInternalServerError)
424 collectionDir, sessionFS, session, tokenUser = f, fs, sess, user
427 if forceReload && collectionDir != nil {
428 err := collectionDir.Sync()
430 if he := errorWithHTTPStatus(nil); errors.As(err, &he) {
431 http.Error(w, err.Error(), he.HTTPStatus())
433 http.Error(w, err.Error(), http.StatusInternalServerError)
440 // The URL is a "secret sharing link" that
441 // didn't work out. Asking the client for
442 // additional credentials would just be
444 http.Error(w, notFoundMessage, http.StatusNotFound)
448 // The client provided valid token(s), but the
449 // collection was not found.
450 http.Error(w, notFoundMessage, http.StatusNotFound)
453 if tokenScopeProblem {
454 // The client provided a valid token but
455 // fetching a collection returned 401, which
456 // means the token scope doesn't permit
457 // fetching that collection.
458 http.Error(w, notFoundMessage, http.StatusForbidden)
461 // The client's token was invalid (e.g., expired), or
462 // the client didn't even provide one. Redirect to
463 // workbench2's login-and-redirect-to-download url if
464 // this is a browser navigation request. (The redirect
465 // flow can't preserve the original method if it's not
466 // GET, and doesn't make sense if the UA is a
467 // command-line tool, is trying to load an inline
468 // image, etc.; in these cases, there's nothing we can
469 // do, so return 401 unauthorized.)
471 // Note Sec-Fetch-Mode is sent by all non-EOL
472 // browsers, except Safari.
473 // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode
475 // TODO(TC): This response would be confusing to
476 // someone trying (anonymously) to download public
477 // data that has been deleted. Allow a referrer to
478 // provide this context somehow?
479 if r.Method == http.MethodGet && r.Header.Get("Sec-Fetch-Mode") == "navigate" {
480 target := url.URL(h.Cluster.Services.Workbench2.ExternalURL)
481 redirkey := "redirectToPreview"
483 redirkey = "redirectToDownload"
485 callback := "/c=" + collectionID + "/" + strings.Join(targetPath, "/")
486 // target.RawQuery = url.Values{redirkey:
487 // {target}}.Encode() would be the obvious
488 // thing to do here, but wb2 doesn't decode
489 // this as a query param -- it takes
490 // everything after "${redirkey}=" as the
491 // target URL. If we encode "/" as "%2F" etc.,
492 // the redirect won't work.
493 target.RawQuery = redirkey + "=" + callback
494 w.Header().Add("Location", target.String())
495 w.WriteHeader(http.StatusSeeOther)
499 http.Error(w, fmt.Sprintf("Authorization tokens are not accepted here: %v, and no anonymous user token is configured.", reasonNotAcceptingCredentials), http.StatusUnauthorized)
502 // If none of the above cases apply, suggest the
503 // user-agent (which is either a non-browser agent
504 // like wget, or a browser that can't redirect through
505 // a login flow) prompt the user for credentials.
506 w.Header().Add("WWW-Authenticate", "Basic realm=\"collections\"")
507 http.Error(w, unauthorizedMessage, http.StatusUnauthorized)
511 if r.Method == http.MethodGet || r.Method == http.MethodHead {
512 targetfnm := fsprefix + strings.Join(pathParts[stripParts:], "/")
513 if fi, err := sessionFS.Stat(targetfnm); err == nil && fi.IsDir() {
514 if !strings.HasSuffix(r.URL.Path, "/") {
515 h.seeOtherWithCookie(w, r, r.URL.Path+"/", credentialsOK)
517 h.serveDirectory(w, r, fi.Name(), sessionFS, targetfnm, !useSiteFS)
524 if len(targetPath) > 0 {
525 basename = targetPath[len(targetPath)-1]
527 if arvadosclient.PDHMatch(collectionID) && writeMethod[r.Method] {
528 http.Error(w, webdavfs.ErrReadOnly.Error(), http.StatusMethodNotAllowed)
531 if !h.userPermittedToUploadOrDownload(r.Method, tokenUser) {
532 http.Error(w, "Not permitted", http.StatusForbidden)
535 h.logUploadOrDownload(r, session.arvadosclient, sessionFS, fsprefix+strings.Join(targetPath, "/"), nil, tokenUser)
537 if writeMethod[r.Method] {
538 // Save the collection only if/when all
539 // webdav->filesystem operations succeed --
540 // and send a 500 error if the modified
541 // collection can't be saved.
543 // Perform the write in a separate sitefs, so
544 // concurrent read operations on the same
545 // collection see the previous saved
546 // state. After the write succeeds and the
547 // collection record is updated, we reset the
548 // session so the updates are visible in
549 // subsequent read requests.
550 client := session.client.WithRequestID(r.Header.Get("X-Request-Id"))
551 sessionFS = client.SiteFileSystem(session.keepclient)
552 writingDir, err := sessionFS.OpenFile(fsprefix, os.O_RDONLY, 0)
554 http.Error(w, err.Error(), http.StatusInternalServerError)
557 defer writingDir.Close()
558 w = &updateOnSuccess{
560 logger: ctxlog.FromContext(r.Context()),
561 update: func() error {
562 err := writingDir.Sync()
563 var te arvados.TransactionError
564 if errors.As(err, &te) {
570 // Sync the changes to the persistent
571 // sessionfs for this token.
572 snap, err := writingDir.Snapshot()
576 collectionDir.Splice(snap)
580 if r.Method == http.MethodGet {
581 applyContentDispositionHdr(w, r, basename, attachment)
583 if webdavPrefix == "" {
584 webdavPrefix = "/" + strings.Join(pathParts[:stripParts], "/")
586 wh := webdav.Handler{
587 Prefix: webdavPrefix,
588 FileSystem: &webdavfs.FS{
589 FileSystem: sessionFS,
591 Writing: writeMethod[r.Method],
592 AlwaysReadEOF: r.Method == "PROPFIND",
594 LockSystem: webdavfs.NoLockSystem,
595 Logger: func(r *http.Request, err error) {
597 ctxlog.FromContext(r.Context()).WithError(err).Error("error reported by webdav handler")
602 if r.Method == http.MethodGet && w.WroteStatus() == http.StatusOK {
603 wrote := int64(w.WroteBodyBytes())
604 fnm := strings.Join(pathParts[stripParts:], "/")
605 fi, err := wh.FileSystem.Stat(r.Context(), fnm)
606 if err == nil && fi.Size() != wrote {
608 f, err := wh.FileSystem.OpenFile(r.Context(), fnm, os.O_RDONLY, 0)
610 n, err = f.Read(make([]byte, 1024))
613 ctxlog.FromContext(r.Context()).Errorf("stat.Size()==%d but only wrote %d bytes; read(1024) returns %d, %v", fi.Size(), wrote, n, err)
618 var dirListingTemplate = `<!DOCTYPE HTML>
620 <META name="robots" content="NOINDEX">
621 <TITLE>{{ .CollectionName }}</TITLE>
622 <STYLE type="text/css">
627 background-color: #D9EDF7;
628 border-radius: .25em;
639 font-family: monospace;
646 <H1>{{ .CollectionName }}</H1>
648 <P>This collection of data files is being shared with you through
649 Arvados. You can download individual files listed below. To download
650 the entire directory tree with wget, try:</P>
652 <PRE>$ wget --mirror --no-parent --no-host --cut-dirs={{ .StripParts }} https://{{ .Request.Host }}{{ .Request.URL.Path }}</PRE>
654 <H2>File Listing</H2>
660 <LI>{{" " | printf "%15s " | nbsp}}<A href="{{print "./" .Name}}/">{{.Name}}/</A></LI>
662 <LI>{{.Size | printf "%15d " | nbsp}}<A href="{{print "./" .Name}}">{{.Name}}</A></LI>
667 <P>(No files; this collection is empty.)</P>
674 Arvados is a free and open source software bioinformatics platform.
675 To learn more, visit arvados.org.
676 Arvados is not responsible for the files listed on this page.
683 type fileListEnt struct {
689 func (h *handler) serveDirectory(w http.ResponseWriter, r *http.Request, collectionName string, fs http.FileSystem, base string, recurse bool) {
690 var files []fileListEnt
691 var walk func(string) error
692 if !strings.HasSuffix(base, "/") {
695 walk = func(path string) error {
696 dirname := base + path
698 dirname = strings.TrimSuffix(dirname, "/")
700 d, err := fs.Open(dirname)
704 ents, err := d.Readdir(-1)
708 for _, ent := range ents {
709 if recurse && ent.IsDir() {
710 err = walk(path + ent.Name() + "/")
715 files = append(files, fileListEnt{
716 Name: path + ent.Name(),
724 if err := walk(""); err != nil {
725 http.Error(w, "error getting directory listing: "+err.Error(), http.StatusInternalServerError)
729 funcs := template.FuncMap{
730 "nbsp": func(s string) template.HTML {
731 return template.HTML(strings.Replace(s, " ", " ", -1))
734 tmpl, err := template.New("dir").Funcs(funcs).Parse(dirListingTemplate)
736 http.Error(w, "error parsing template: "+err.Error(), http.StatusInternalServerError)
739 sort.Slice(files, func(i, j int) bool {
740 return files[i].Name < files[j].Name
742 w.WriteHeader(http.StatusOK)
743 tmpl.Execute(w, map[string]interface{}{
744 "CollectionName": collectionName,
747 "StripParts": strings.Count(strings.TrimRight(r.URL.Path, "/"), "/"),
751 func applyContentDispositionHdr(w http.ResponseWriter, r *http.Request, filename string, isAttachment bool) {
752 disposition := "inline"
754 disposition = "attachment"
756 if strings.ContainsRune(r.RequestURI, '?') {
757 // Help the UA realize that the filename is just
758 // "filename.txt", not
759 // "filename.txt?disposition=attachment".
761 // TODO(TC): Follow advice at RFC 6266 appendix D
762 disposition += "; filename=" + strconv.QuoteToASCII(filename)
764 if disposition != "inline" {
765 w.Header().Set("Content-Disposition", disposition)
769 func (h *handler) seeOtherWithCookie(w http.ResponseWriter, r *http.Request, location string, credentialsOK bool) {
770 if formToken := r.FormValue("api_token"); formToken != "" {
772 // It is not safe to copy the provided token
773 // into a cookie unless the current vhost
774 // (origin) serves only a single collection or
775 // we are in TrustAllContent mode.
776 http.Error(w, "cannot serve inline content at this URL (possible configuration error; see https://doc.arvados.org/install/install-keep-web.html#dns)", http.StatusBadRequest)
780 // The HttpOnly flag is necessary to prevent
781 // JavaScript code (included in, or loaded by, a page
782 // in the collection being served) from employing the
783 // user's token beyond reading other files in the same
784 // domain, i.e., same collection.
786 // The 303 redirect is necessary in the case of a GET
787 // request to avoid exposing the token in the Location
788 // bar, and in the case of a POST request to avoid
789 // raising warnings when the user refreshes the
791 http.SetCookie(w, &http.Cookie{
792 Name: "arvados_api_token",
793 Value: auth.EncodeTokenCookie([]byte(formToken)),
796 SameSite: http.SameSiteLaxMode,
800 // Propagate query parameters (except api_token) from
801 // the original request.
802 redirQuery := r.URL.Query()
803 redirQuery.Del("api_token")
807 newu, err := u.Parse(location)
809 http.Error(w, "error resolving redirect target: "+err.Error(), http.StatusInternalServerError)
815 Scheme: r.URL.Scheme,
818 RawQuery: redirQuery.Encode(),
821 w.Header().Add("Location", redir)
822 w.WriteHeader(http.StatusSeeOther)
823 io.WriteString(w, `<A href="`)
824 io.WriteString(w, html.EscapeString(redir))
825 io.WriteString(w, `">Continue</A>`)
828 func (h *handler) userPermittedToUploadOrDownload(method string, tokenUser *arvados.User) bool {
829 var permitDownload bool
830 var permitUpload bool
831 if tokenUser != nil && tokenUser.IsAdmin {
832 permitUpload = h.Cluster.Collections.WebDAVPermission.Admin.Upload
833 permitDownload = h.Cluster.Collections.WebDAVPermission.Admin.Download
835 permitUpload = h.Cluster.Collections.WebDAVPermission.User.Upload
836 permitDownload = h.Cluster.Collections.WebDAVPermission.User.Download
838 if (method == "PUT" || method == "POST") && !permitUpload {
839 // Disallow operations that upload new files.
840 // Permit webdav operations that move existing files around.
842 } else if method == "GET" && !permitDownload {
843 // Disallow downloading file contents.
844 // Permit webdav operations like PROPFIND that retrieve metadata
845 // but not file contents.
851 func (h *handler) logUploadOrDownload(
853 client *arvadosclient.ArvadosClient,
854 fs arvados.CustomFileSystem,
856 collection *arvados.Collection,
857 user *arvados.User) {
859 log := ctxlog.FromContext(r.Context())
860 props := make(map[string]string)
861 props["reqPath"] = r.URL.Path
864 log = log.WithField("user_uuid", user.UUID).
865 WithField("user_full_name", user.FullName)
868 useruuid = fmt.Sprintf("%s-tpzed-anonymouspublic", h.Cluster.ClusterID)
870 if collection == nil && fs != nil {
871 collection, filepath = h.determineCollection(fs, filepath)
873 if collection != nil {
874 log = log.WithField("collection_file_path", filepath)
875 props["collection_file_path"] = filepath
876 // h.determineCollection populates the collection_uuid
877 // prop with the PDH, if this collection is being
878 // accessed via PDH. For logging, we use a different
879 // field depending on whether it's a UUID or PDH.
880 if len(collection.UUID) > 32 {
881 log = log.WithField("portable_data_hash", collection.UUID)
882 props["portable_data_hash"] = collection.UUID
884 log = log.WithField("collection_uuid", collection.UUID)
885 props["collection_uuid"] = collection.UUID
888 if r.Method == "PUT" || r.Method == "POST" {
889 log.Info("File upload")
890 if h.Cluster.Collections.WebDAVLogEvents {
892 lr := arvadosclient.Dict{"log": arvadosclient.Dict{
893 "object_uuid": useruuid,
894 "event_type": "file_upload",
895 "properties": props}}
896 err := client.Create("logs", lr, nil)
898 log.WithError(err).Error("Failed to create upload log event on API server")
902 } else if r.Method == "GET" {
903 if collection != nil && collection.PortableDataHash != "" {
904 log = log.WithField("portable_data_hash", collection.PortableDataHash)
905 props["portable_data_hash"] = collection.PortableDataHash
907 log.Info("File download")
908 if h.Cluster.Collections.WebDAVLogEvents {
910 lr := arvadosclient.Dict{"log": arvadosclient.Dict{
911 "object_uuid": useruuid,
912 "event_type": "file_download",
913 "properties": props}}
914 err := client.Create("logs", lr, nil)
916 log.WithError(err).Error("Failed to create download log event on API server")
923 func (h *handler) determineCollection(fs arvados.CustomFileSystem, path string) (*arvados.Collection, string) {
924 target := strings.TrimSuffix(path, "/")
925 for cut := len(target); cut >= 0; cut = strings.LastIndexByte(target, '/') {
926 target = target[:cut]
927 fi, err := fs.Stat(target)
928 if os.IsNotExist(err) {
929 // creating a new file/dir, or download
932 } else if err != nil {
935 switch src := fi.Sys().(type) {
936 case *arvados.Collection:
937 return src, strings.TrimPrefix(path[len(target):], "/")
941 if _, ok := src.(error); ok {