1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
22 "git.arvados.org/arvados.git/sdk/go/arvados"
23 "git.arvados.org/arvados.git/sdk/go/arvadosclient"
24 "git.arvados.org/arvados.git/sdk/go/auth"
25 "git.arvados.org/arvados.git/sdk/go/ctxlog"
26 "git.arvados.org/arvados.git/sdk/go/httpserver"
27 "git.arvados.org/arvados.git/sdk/go/keepclient"
28 "github.com/sirupsen/logrus"
29 "golang.org/x/net/webdav"
34 Cluster *arvados.Cluster
36 webdavLS webdav.LockSystem
39 var urlPDHDecoder = strings.NewReplacer(" ", "+", "-", "+")
41 var notFoundMessage = "Not Found"
42 var unauthorizedMessage = "401 Unauthorized\r\n\r\nA valid Arvados token must be provided to access this resource.\r\n"
44 // parseCollectionIDFromURL returns a UUID or PDH if s is a UUID or a
45 // PDH (even if it is a PDH with "+" replaced by " " or "-");
47 func parseCollectionIDFromURL(s string) string {
48 if arvadosclient.UUIDMatch(s) {
51 if pdh := urlPDHDecoder.Replace(s); arvadosclient.PDHMatch(pdh) {
57 func (h *handler) setup() {
58 keepclient.DefaultBlockCache.MaxBlocks = h.Cluster.Collections.WebDAVCache.MaxBlockEntries
60 // Even though we don't accept LOCK requests, every webdav
61 // handler must have a non-nil LockSystem.
62 h.webdavLS = &noLockSystem{}
65 func (h *handler) serveStatus(w http.ResponseWriter, r *http.Request) {
66 json.NewEncoder(w).Encode(struct{ Version string }{version})
69 // updateOnSuccess wraps httpserver.ResponseWriter. If the handler
70 // sends an HTTP header indicating success, updateOnSuccess first
71 // calls the provided update func. If the update func fails, an error
72 // response is sent (using the error's HTTP status or 500 if none),
73 // and the status code and body sent by the handler are ignored (all
74 // response writes return the update error).
75 type updateOnSuccess struct {
76 httpserver.ResponseWriter
77 logger logrus.FieldLogger
83 func (uos *updateOnSuccess) Write(p []byte) (int, error) {
85 uos.WriteHeader(http.StatusOK)
90 return uos.ResponseWriter.Write(p)
93 func (uos *updateOnSuccess) WriteHeader(code int) {
96 if code >= 200 && code < 400 {
97 if uos.err = uos.update(); uos.err != nil {
98 code := http.StatusInternalServerError
99 var he interface{ HTTPStatus() int }
100 if 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",
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")
216 pathParts := strings.Split(r.URL.Path[1:], "/")
219 var collectionID string
221 var reqTokens []string
225 credentialsOK := h.Cluster.Collections.TrustAllContent
226 reasonNotAcceptingCredentials := ""
228 if r.Host != "" && stripDefaultPort(r.Host) == stripDefaultPort(h.Cluster.Services.WebDAVDownload.ExternalURL.Host) {
231 } else if r.FormValue("disposition") == "attachment" {
236 reasonNotAcceptingCredentials = fmt.Sprintf("vhost %q does not specify a single collection ID or match Services.WebDAVDownload.ExternalURL %q, and Collections.TrustAllContent is false",
237 r.Host, h.Cluster.Services.WebDAVDownload.ExternalURL)
240 if collectionID = arvados.CollectionIDFromDNSName(r.Host); collectionID != "" {
241 // http://ID.collections.example/PATH...
243 } else if r.URL.Path == "/status.json" {
246 } else if siteFSDir[pathParts[0]] {
248 } else if len(pathParts) >= 1 && strings.HasPrefix(pathParts[0], "c=") {
250 collectionID = parseCollectionIDFromURL(pathParts[0][2:])
252 } else if len(pathParts) >= 2 && pathParts[0] == "collections" {
253 if len(pathParts) >= 4 && pathParts[1] == "download" {
254 // /collections/download/ID/TOKEN/PATH...
255 collectionID = parseCollectionIDFromURL(pathParts[2])
256 tokens = []string{pathParts[3]}
260 // /collections/ID/PATH...
261 collectionID = parseCollectionIDFromURL(pathParts[1])
263 // This path is only meant to work for public
264 // data. Tokens provided with the request are
266 credentialsOK = false
267 reasonNotAcceptingCredentials = "the '/collections/UUID/PATH' form only works for public data"
272 if cc := r.Header.Get("Cache-Control"); strings.Contains(cc, "no-cache") || strings.Contains(cc, "must-revalidate") {
277 reqTokens = auth.CredentialsFromRequest(r).Tokens
280 formToken := r.FormValue("api_token")
281 origin := r.Header.Get("Origin")
282 cors := origin != "" && !strings.HasSuffix(origin, "://"+r.Host)
283 safeAjax := cors && (r.Method == http.MethodGet || r.Method == http.MethodHead)
284 safeAttachment := attachment && r.URL.Query().Get("api_token") == ""
286 // No token to use or redact.
287 } else if safeAjax || safeAttachment {
288 // If this is a cross-origin request, the URL won't
289 // appear in the browser's address bar, so
290 // substituting a clipboard-safe URL is pointless.
291 // Redirect-with-cookie wouldn't work anyway, because
292 // it's not safe to allow third-party use of our
295 // If we're supplying an attachment, we don't need to
296 // convert POST to GET to avoid the "really resubmit
297 // form?" problem, so provided the token isn't
298 // embedded in the URL, there's no reason to do
299 // redirect-with-cookie in this case either.
300 reqTokens = append(reqTokens, formToken)
301 } else if browserMethod[r.Method] {
302 // If this is a page view, and the client provided a
303 // token via query string or POST body, we must put
304 // the token in an HttpOnly cookie, and redirect to an
305 // equivalent URL with the query param redacted and
307 h.seeOtherWithCookie(w, r, "", credentialsOK)
311 targetPath := pathParts[stripParts:]
312 if tokens == nil && len(targetPath) > 0 && strings.HasPrefix(targetPath[0], "t=") {
313 // http://ID.example/t=TOKEN/PATH...
314 // /c=ID/t=TOKEN/PATH...
316 // This form must only be used to pass scoped tokens
317 // that give permission for a single collection. See
318 // FormValue case above.
319 tokens = []string{targetPath[0][2:]}
321 targetPath = targetPath[1:]
327 if writeMethod[r.Method] {
328 http.Error(w, errReadOnly.Error(), http.StatusMethodNotAllowed)
331 if len(reqTokens) == 0 {
332 w.Header().Add("WWW-Authenticate", "Basic realm=\"collections\"")
333 http.Error(w, unauthorizedMessage, http.StatusUnauthorized)
337 } else if collectionID == "" {
338 http.Error(w, notFoundMessage, http.StatusNotFound)
341 fsprefix = "by_id/" + collectionID + "/"
346 if h.Cluster.Users.AnonymousUserToken != "" {
347 tokens = append(tokens, h.Cluster.Users.AnonymousUserToken)
353 http.Error(w, fmt.Sprintf("Authorization tokens are not accepted here: %v, and no anonymous user token is configured.", reasonNotAcceptingCredentials), http.StatusUnauthorized)
355 http.Error(w, fmt.Sprintf("No authorization token in request, and no anonymous user token is configured."), http.StatusUnauthorized)
360 if len(targetPath) > 0 && targetPath[0] == "_" {
361 // If a collection has a directory called "t=foo" or
362 // "_", it can be served at
363 // //collections.example/_/t=foo/ or
364 // //collections.example/_/_/ respectively:
365 // //collections.example/t=foo/ won't work because
366 // t=foo will be interpreted as a token "foo".
367 targetPath = targetPath[1:]
371 dirOpenMode := os.O_RDONLY
372 if writeMethod[r.Method] {
373 dirOpenMode = os.O_RDWR
376 validToken := make(map[string]bool)
378 var tokenUser *arvados.User
379 var sessionFS arvados.CustomFileSystem
380 var session *cachedSession
381 var collectionDir arvados.File
382 for _, token = range tokens {
383 var statusErr interface{ HTTPStatus() int }
384 fs, sess, user, err := h.Cache.GetSession(token)
385 if errors.As(err, &statusErr) && statusErr.HTTPStatus() == http.StatusUnauthorized {
388 } else if err != nil {
389 http.Error(w, "cache error: "+err.Error(), http.StatusInternalServerError)
392 f, err := fs.OpenFile(fsprefix, dirOpenMode, 0)
393 if errors.As(err, &statusErr) && statusErr.HTTPStatus() == http.StatusForbidden {
394 // collection id is outside token scope
395 validToken[token] = true
398 validToken[token] = true
399 if os.IsNotExist(err) {
400 // collection does not exist or is not
401 // readable using this token
403 } else if err != nil {
404 http.Error(w, err.Error(), http.StatusInternalServerError)
409 collectionDir, sessionFS, session, tokenUser = f, fs, sess, user
413 err := collectionDir.Sync()
415 var statusErr interface{ HTTPStatus() int }
416 if errors.As(err, &statusErr) {
417 http.Error(w, err.Error(), statusErr.HTTPStatus())
419 http.Error(w, err.Error(), http.StatusInternalServerError)
425 if pathToken || !credentialsOK {
426 // Either the URL is a "secret sharing link"
427 // that didn't work out (and asking the client
428 // for additional credentials would just be
429 // confusing), or we don't even accept
430 // credentials at this path.
431 http.Error(w, notFoundMessage, http.StatusNotFound)
434 for _, t := range reqTokens {
436 // The client provided valid token(s),
437 // but the collection was not found.
438 http.Error(w, notFoundMessage, http.StatusNotFound)
442 // The client's token was invalid (e.g., expired), or
443 // the client didn't even provide one. Redirect to
444 // workbench2's login-and-redirect-to-download url if
445 // this is a browser navigation request. (The redirect
446 // flow can't preserve the original method if it's not
447 // GET, and doesn't make sense if the UA is a
448 // command-line tool, is trying to load an inline
449 // image, etc.; in these cases, there's nothing we can
450 // do, so return 401 unauthorized.)
452 // Note Sec-Fetch-Mode is sent by all non-EOL
453 // browsers, except Safari.
454 // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode
456 // TODO(TC): This response would be confusing to
457 // someone trying (anonymously) to download public
458 // data that has been deleted. Allow a referrer to
459 // provide this context somehow?
460 if r.Method == http.MethodGet && r.Header.Get("Sec-Fetch-Mode") == "navigate" {
461 target := url.URL(h.Cluster.Services.Workbench2.ExternalURL)
462 redirkey := "redirectToPreview"
464 redirkey = "redirectToDownload"
466 callback := "/c=" + collectionID + "/" + strings.Join(targetPath, "/")
467 // target.RawQuery = url.Values{redirkey:
468 // {target}}.Encode() would be the obvious
469 // thing to do here, but wb2 doesn't decode
470 // this as a query param -- it takes
471 // everything after "${redirkey}=" as the
472 // target URL. If we encode "/" as "%2F" etc.,
473 // the redirect won't work.
474 target.RawQuery = redirkey + "=" + callback
475 w.Header().Add("Location", target.String())
476 w.WriteHeader(http.StatusSeeOther)
478 w.Header().Add("WWW-Authenticate", "Basic realm=\"collections\"")
479 http.Error(w, unauthorizedMessage, http.StatusUnauthorized)
484 if r.Method == http.MethodGet || r.Method == http.MethodHead {
485 targetfnm := fsprefix + strings.Join(pathParts[stripParts:], "/")
486 if fi, err := sessionFS.Stat(targetfnm); err == nil && fi.IsDir() {
487 if !strings.HasSuffix(r.URL.Path, "/") {
488 h.seeOtherWithCookie(w, r, r.URL.Path+"/", credentialsOK)
490 h.serveDirectory(w, r, fi.Name(), sessionFS, targetfnm, !useSiteFS)
497 if len(targetPath) > 0 {
498 basename = targetPath[len(targetPath)-1]
500 if arvadosclient.PDHMatch(collectionID) && writeMethod[r.Method] {
501 http.Error(w, errReadOnly.Error(), http.StatusMethodNotAllowed)
504 if !h.userPermittedToUploadOrDownload(r.Method, tokenUser) {
505 http.Error(w, "Not permitted", http.StatusForbidden)
508 h.logUploadOrDownload(r, session.arvadosclient, sessionFS, fsprefix+strings.Join(targetPath, "/"), nil, tokenUser)
510 if writeMethod[r.Method] {
511 // Save the collection only if/when all
512 // webdav->filesystem operations succeed --
513 // and send a 500 error if the modified
514 // collection can't be saved.
516 // Perform the write in a separate sitefs, so
517 // concurrent read operations on the same
518 // collection see the previous saved
519 // state. After the write succeeds and the
520 // collection record is updated, we reset the
521 // session so the updates are visible in
522 // subsequent read requests.
523 client := session.client.WithRequestID(r.Header.Get("X-Request-Id"))
524 sessionFS = client.SiteFileSystem(session.keepclient)
525 writingDir, err := sessionFS.OpenFile(fsprefix, os.O_RDONLY, 0)
527 http.Error(w, err.Error(), http.StatusInternalServerError)
530 defer writingDir.Close()
531 w = &updateOnSuccess{
533 logger: ctxlog.FromContext(r.Context()),
534 update: func() error {
535 err := writingDir.Sync()
536 var te arvados.TransactionError
537 if errors.As(err, &te) {
543 // Sync the changes to the persistent
544 // sessionfs for this token.
545 snap, err := writingDir.Snapshot()
549 collectionDir.Splice(snap)
553 if r.Method == http.MethodGet {
554 applyContentDispositionHdr(w, r, basename, attachment)
556 wh := webdav.Handler{
557 Prefix: "/" + strings.Join(pathParts[:stripParts], "/"),
558 FileSystem: &webdavFS{
561 writing: writeMethod[r.Method],
562 alwaysReadEOF: r.Method == "PROPFIND",
564 LockSystem: h.webdavLS,
565 Logger: func(r *http.Request, err error) {
567 ctxlog.FromContext(r.Context()).WithError(err).Error("error reported by webdav handler")
572 if r.Method == http.MethodGet && w.WroteStatus() == http.StatusOK {
573 wrote := int64(w.WroteBodyBytes())
574 fnm := strings.Join(pathParts[stripParts:], "/")
575 fi, err := wh.FileSystem.Stat(r.Context(), fnm)
576 if err == nil && fi.Size() != wrote {
578 f, err := wh.FileSystem.OpenFile(r.Context(), fnm, os.O_RDONLY, 0)
580 n, err = f.Read(make([]byte, 1024))
583 ctxlog.FromContext(r.Context()).Errorf("stat.Size()==%d but only wrote %d bytes; read(1024) returns %d, %v", fi.Size(), wrote, n, err)
588 var dirListingTemplate = `<!DOCTYPE HTML>
590 <META name="robots" content="NOINDEX">
591 <TITLE>{{ .CollectionName }}</TITLE>
592 <STYLE type="text/css">
597 background-color: #D9EDF7;
598 border-radius: .25em;
609 font-family: monospace;
616 <H1>{{ .CollectionName }}</H1>
618 <P>This collection of data files is being shared with you through
619 Arvados. You can download individual files listed below. To download
620 the entire directory tree with wget, try:</P>
622 <PRE>$ wget --mirror --no-parent --no-host --cut-dirs={{ .StripParts }} https://{{ .Request.Host }}{{ .Request.URL.Path }}</PRE>
624 <H2>File Listing</H2>
630 <LI>{{" " | printf "%15s " | nbsp}}<A href="{{print "./" .Name}}/">{{.Name}}/</A></LI>
632 <LI>{{.Size | printf "%15d " | nbsp}}<A href="{{print "./" .Name}}">{{.Name}}</A></LI>
637 <P>(No files; this collection is empty.)</P>
644 Arvados is a free and open source software bioinformatics platform.
645 To learn more, visit arvados.org.
646 Arvados is not responsible for the files listed on this page.
653 type fileListEnt struct {
659 func (h *handler) serveDirectory(w http.ResponseWriter, r *http.Request, collectionName string, fs http.FileSystem, base string, recurse bool) {
660 var files []fileListEnt
661 var walk func(string) error
662 if !strings.HasSuffix(base, "/") {
665 walk = func(path string) error {
666 dirname := base + path
668 dirname = strings.TrimSuffix(dirname, "/")
670 d, err := fs.Open(dirname)
674 ents, err := d.Readdir(-1)
678 for _, ent := range ents {
679 if recurse && ent.IsDir() {
680 err = walk(path + ent.Name() + "/")
685 files = append(files, fileListEnt{
686 Name: path + ent.Name(),
694 if err := walk(""); err != nil {
695 http.Error(w, "error getting directory listing: "+err.Error(), http.StatusInternalServerError)
699 funcs := template.FuncMap{
700 "nbsp": func(s string) template.HTML {
701 return template.HTML(strings.Replace(s, " ", " ", -1))
704 tmpl, err := template.New("dir").Funcs(funcs).Parse(dirListingTemplate)
706 http.Error(w, "error parsing template: "+err.Error(), http.StatusInternalServerError)
709 sort.Slice(files, func(i, j int) bool {
710 return files[i].Name < files[j].Name
712 w.WriteHeader(http.StatusOK)
713 tmpl.Execute(w, map[string]interface{}{
714 "CollectionName": collectionName,
717 "StripParts": strings.Count(strings.TrimRight(r.URL.Path, "/"), "/"),
721 func applyContentDispositionHdr(w http.ResponseWriter, r *http.Request, filename string, isAttachment bool) {
722 disposition := "inline"
724 disposition = "attachment"
726 if strings.ContainsRune(r.RequestURI, '?') {
727 // Help the UA realize that the filename is just
728 // "filename.txt", not
729 // "filename.txt?disposition=attachment".
731 // TODO(TC): Follow advice at RFC 6266 appendix D
732 disposition += "; filename=" + strconv.QuoteToASCII(filename)
734 if disposition != "inline" {
735 w.Header().Set("Content-Disposition", disposition)
739 func (h *handler) seeOtherWithCookie(w http.ResponseWriter, r *http.Request, location string, credentialsOK bool) {
740 if formToken := r.FormValue("api_token"); formToken != "" {
742 // It is not safe to copy the provided token
743 // into a cookie unless the current vhost
744 // (origin) serves only a single collection or
745 // we are in TrustAllContent mode.
746 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)
750 // The HttpOnly flag is necessary to prevent
751 // JavaScript code (included in, or loaded by, a page
752 // in the collection being served) from employing the
753 // user's token beyond reading other files in the same
754 // domain, i.e., same collection.
756 // The 303 redirect is necessary in the case of a GET
757 // request to avoid exposing the token in the Location
758 // bar, and in the case of a POST request to avoid
759 // raising warnings when the user refreshes the
761 http.SetCookie(w, &http.Cookie{
762 Name: "arvados_api_token",
763 Value: auth.EncodeTokenCookie([]byte(formToken)),
766 SameSite: http.SameSiteLaxMode,
770 // Propagate query parameters (except api_token) from
771 // the original request.
772 redirQuery := r.URL.Query()
773 redirQuery.Del("api_token")
777 newu, err := u.Parse(location)
779 http.Error(w, "error resolving redirect target: "+err.Error(), http.StatusInternalServerError)
785 Scheme: r.URL.Scheme,
788 RawQuery: redirQuery.Encode(),
791 w.Header().Add("Location", redir)
792 w.WriteHeader(http.StatusSeeOther)
793 io.WriteString(w, `<A href="`)
794 io.WriteString(w, html.EscapeString(redir))
795 io.WriteString(w, `">Continue</A>`)
798 func (h *handler) userPermittedToUploadOrDownload(method string, tokenUser *arvados.User) bool {
799 var permitDownload bool
800 var permitUpload bool
801 if tokenUser != nil && tokenUser.IsAdmin {
802 permitUpload = h.Cluster.Collections.WebDAVPermission.Admin.Upload
803 permitDownload = h.Cluster.Collections.WebDAVPermission.Admin.Download
805 permitUpload = h.Cluster.Collections.WebDAVPermission.User.Upload
806 permitDownload = h.Cluster.Collections.WebDAVPermission.User.Download
808 if (method == "PUT" || method == "POST") && !permitUpload {
809 // Disallow operations that upload new files.
810 // Permit webdav operations that move existing files around.
812 } else if method == "GET" && !permitDownload {
813 // Disallow downloading file contents.
814 // Permit webdav operations like PROPFIND that retrieve metadata
815 // but not file contents.
821 func (h *handler) logUploadOrDownload(
823 client *arvadosclient.ArvadosClient,
824 fs arvados.CustomFileSystem,
826 collection *arvados.Collection,
827 user *arvados.User) {
829 log := ctxlog.FromContext(r.Context())
830 props := make(map[string]string)
831 props["reqPath"] = r.URL.Path
834 log = log.WithField("user_uuid", user.UUID).
835 WithField("user_full_name", user.FullName)
838 useruuid = fmt.Sprintf("%s-tpzed-anonymouspublic", h.Cluster.ClusterID)
840 if collection == nil && fs != nil {
841 collection, filepath = h.determineCollection(fs, filepath)
843 if collection != nil {
844 log = log.WithField("collection_file_path", filepath)
845 props["collection_file_path"] = filepath
846 // h.determineCollection populates the collection_uuid
847 // prop with the PDH, if this collection is being
848 // accessed via PDH. For logging, we use a different
849 // field depending on whether it's a UUID or PDH.
850 if len(collection.UUID) > 32 {
851 log = log.WithField("portable_data_hash", collection.UUID)
852 props["portable_data_hash"] = collection.UUID
854 log = log.WithField("collection_uuid", collection.UUID)
855 props["collection_uuid"] = collection.UUID
858 if r.Method == "PUT" || r.Method == "POST" {
859 log.Info("File upload")
860 if h.Cluster.Collections.WebDAVLogEvents {
862 lr := arvadosclient.Dict{"log": arvadosclient.Dict{
863 "object_uuid": useruuid,
864 "event_type": "file_upload",
865 "properties": props}}
866 err := client.Create("logs", lr, nil)
868 log.WithError(err).Error("Failed to create upload log event on API server")
872 } else if r.Method == "GET" {
873 if collection != nil && collection.PortableDataHash != "" {
874 log = log.WithField("portable_data_hash", collection.PortableDataHash)
875 props["portable_data_hash"] = collection.PortableDataHash
877 log.Info("File download")
878 if h.Cluster.Collections.WebDAVLogEvents {
880 lr := arvadosclient.Dict{"log": arvadosclient.Dict{
881 "object_uuid": useruuid,
882 "event_type": "file_download",
883 "properties": props}}
884 err := client.Create("logs", lr, nil)
886 log.WithError(err).Error("Failed to create download log event on API server")
893 func (h *handler) determineCollection(fs arvados.CustomFileSystem, path string) (*arvados.Collection, string) {
894 target := strings.TrimSuffix(path, "/")
895 for cut := len(target); cut >= 0; cut = strings.LastIndexByte(target, '/') {
896 target = target[:cut]
897 fi, err := fs.Stat(target)
898 if os.IsNotExist(err) {
899 // creating a new file/dir, or download
902 } else if err != nil {
905 switch src := fi.Sys().(type) {
906 case *arvados.Collection:
907 return src, strings.TrimPrefix(path[len(target):], "/")
911 if _, ok := src.(error); ok {