Refactor the multi-host salt install page.
[arvados.git] / services / keep-web / handler.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package keepweb
6
7 import (
8         "encoding/json"
9         "fmt"
10         "html"
11         "html/template"
12         "io"
13         "net/http"
14         "net/url"
15         "os"
16         "path/filepath"
17         "sort"
18         "strconv"
19         "strings"
20         "sync"
21
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"
30 )
31
32 type handler struct {
33         Cache      cache
34         Cluster    *arvados.Cluster
35         clientPool *arvadosclient.ClientPool
36         setupOnce  sync.Once
37         webdavLS   webdav.LockSystem
38 }
39
40 var urlPDHDecoder = strings.NewReplacer(" ", "+", "-", "+")
41
42 var notFoundMessage = "404 Not found\r\n\r\nThe requested path was not found, or you do not have permission to access it.\r"
43 var unauthorizedMessage = "401 Unauthorized\r\n\r\nA valid Arvados token must be provided to access this resource.\r"
44
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 "-");
47 // otherwise "".
48 func parseCollectionIDFromURL(s string) string {
49         if arvadosclient.UUIDMatch(s) {
50                 return s
51         }
52         if pdh := urlPDHDecoder.Replace(s); arvadosclient.PDHMatch(pdh) {
53                 return pdh
54         }
55         return ""
56 }
57
58 func (h *handler) setup() {
59         // Errors will be handled at the client pool.
60         arv, _ := arvados.NewClientFromConfig(h.Cluster)
61         h.clientPool = arvadosclient.MakeClientPoolWith(arv)
62
63         keepclient.DefaultBlockCache.MaxBlocks = h.Cluster.Collections.WebDAVCache.MaxBlockEntries
64
65         // Even though we don't accept LOCK requests, every webdav
66         // handler must have a non-nil LockSystem.
67         h.webdavLS = &noLockSystem{}
68 }
69
70 func (h *handler) serveStatus(w http.ResponseWriter, r *http.Request) {
71         json.NewEncoder(w).Encode(struct{ Version string }{version})
72 }
73
74 // updateOnSuccess wraps httpserver.ResponseWriter. If the handler
75 // sends an HTTP header indicating success, updateOnSuccess first
76 // calls the provided update func. If the update func fails, a 500
77 // response is sent, and the status code and body sent by the handler
78 // are ignored (all response writes return the update error).
79 type updateOnSuccess struct {
80         httpserver.ResponseWriter
81         logger     logrus.FieldLogger
82         update     func() error
83         sentHeader bool
84         err        error
85 }
86
87 func (uos *updateOnSuccess) Write(p []byte) (int, error) {
88         if !uos.sentHeader {
89                 uos.WriteHeader(http.StatusOK)
90         }
91         if uos.err != nil {
92                 return 0, uos.err
93         }
94         return uos.ResponseWriter.Write(p)
95 }
96
97 func (uos *updateOnSuccess) WriteHeader(code int) {
98         if !uos.sentHeader {
99                 uos.sentHeader = true
100                 if code >= 200 && code < 400 {
101                         if uos.err = uos.update(); uos.err != nil {
102                                 code := http.StatusInternalServerError
103                                 if err, ok := uos.err.(*arvados.TransactionError); ok {
104                                         code = err.StatusCode
105                                 }
106                                 uos.logger.WithError(uos.err).Errorf("update() returned error type %T, changing response to HTTP %d", uos.err, code)
107                                 http.Error(uos.ResponseWriter, uos.err.Error(), code)
108                                 return
109                         }
110                 }
111         }
112         uos.ResponseWriter.WriteHeader(code)
113 }
114
115 var (
116         corsAllowHeadersHeader = strings.Join([]string{
117                 "Authorization", "Content-Type", "Range",
118                 // WebDAV request headers:
119                 "Depth", "Destination", "If", "Lock-Token", "Overwrite", "Timeout",
120         }, ", ")
121         writeMethod = map[string]bool{
122                 "COPY":      true,
123                 "DELETE":    true,
124                 "LOCK":      true,
125                 "MKCOL":     true,
126                 "MOVE":      true,
127                 "PROPPATCH": true,
128                 "PUT":       true,
129                 "RMCOL":     true,
130                 "UNLOCK":    true,
131         }
132         webdavMethod = map[string]bool{
133                 "COPY":      true,
134                 "DELETE":    true,
135                 "LOCK":      true,
136                 "MKCOL":     true,
137                 "MOVE":      true,
138                 "OPTIONS":   true,
139                 "PROPFIND":  true,
140                 "PROPPATCH": true,
141                 "PUT":       true,
142                 "RMCOL":     true,
143                 "UNLOCK":    true,
144         }
145         browserMethod = map[string]bool{
146                 "GET":  true,
147                 "HEAD": true,
148                 "POST": true,
149         }
150         // top-level dirs to serve with siteFS
151         siteFSDir = map[string]bool{
152                 "":      true, // root directory
153                 "by_id": true,
154                 "users": true,
155         }
156 )
157
158 func stripDefaultPort(host string) string {
159         // Will consider port 80 and port 443 to be the same vhost.  I think that's fine.
160         u := &url.URL{Host: host}
161         if p := u.Port(); p == "80" || p == "443" {
162                 return strings.ToLower(u.Hostname())
163         } else {
164                 return strings.ToLower(host)
165         }
166 }
167
168 // CheckHealth implements service.Handler.
169 func (h *handler) CheckHealth() error {
170         return nil
171 }
172
173 // Done implements service.Handler.
174 func (h *handler) Done() <-chan struct{} {
175         return nil
176 }
177
178 // ServeHTTP implements http.Handler.
179 func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
180         h.setupOnce.Do(h.setup)
181
182         if xfp := r.Header.Get("X-Forwarded-Proto"); xfp != "" && xfp != "http" {
183                 r.URL.Scheme = xfp
184         }
185
186         w := httpserver.WrapResponseWriter(wOrig)
187
188         if method := r.Header.Get("Access-Control-Request-Method"); method != "" && r.Method == "OPTIONS" {
189                 if !browserMethod[method] && !webdavMethod[method] {
190                         w.WriteHeader(http.StatusMethodNotAllowed)
191                         return
192                 }
193                 w.Header().Set("Access-Control-Allow-Headers", corsAllowHeadersHeader)
194                 w.Header().Set("Access-Control-Allow-Methods", "COPY, DELETE, GET, LOCK, MKCOL, MOVE, OPTIONS, POST, PROPFIND, PROPPATCH, PUT, RMCOL, UNLOCK")
195                 w.Header().Set("Access-Control-Allow-Origin", "*")
196                 w.Header().Set("Access-Control-Max-Age", "86400")
197                 return
198         }
199
200         if !browserMethod[r.Method] && !webdavMethod[r.Method] {
201                 w.WriteHeader(http.StatusMethodNotAllowed)
202                 return
203         }
204
205         if r.Header.Get("Origin") != "" {
206                 // Allow simple cross-origin requests without user
207                 // credentials ("user credentials" as defined by CORS,
208                 // i.e., cookies, HTTP authentication, and client-side
209                 // SSL certificates. See
210                 // http://www.w3.org/TR/cors/#user-credentials).
211                 w.Header().Set("Access-Control-Allow-Origin", "*")
212                 w.Header().Set("Access-Control-Expose-Headers", "Content-Range")
213         }
214
215         if h.serveS3(w, r) {
216                 return
217         }
218
219         pathParts := strings.Split(r.URL.Path[1:], "/")
220
221         var stripParts int
222         var collectionID string
223         var tokens []string
224         var reqTokens []string
225         var pathToken bool
226         var attachment bool
227         var useSiteFS bool
228         credentialsOK := h.Cluster.Collections.TrustAllContent
229         reasonNotAcceptingCredentials := ""
230
231         if r.Host != "" && stripDefaultPort(r.Host) == stripDefaultPort(h.Cluster.Services.WebDAVDownload.ExternalURL.Host) {
232                 credentialsOK = true
233                 attachment = true
234         } else if r.FormValue("disposition") == "attachment" {
235                 attachment = true
236         }
237
238         if !credentialsOK {
239                 reasonNotAcceptingCredentials = fmt.Sprintf("vhost %q does not specify a single collection ID or match Services.WebDAVDownload.ExternalURL %q, and Collections.TrustAllContent is false",
240                         r.Host, h.Cluster.Services.WebDAVDownload.ExternalURL)
241         }
242
243         if collectionID = arvados.CollectionIDFromDNSName(r.Host); collectionID != "" {
244                 // http://ID.collections.example/PATH...
245                 credentialsOK = true
246         } else if r.URL.Path == "/status.json" {
247                 h.serveStatus(w, r)
248                 return
249         } else if siteFSDir[pathParts[0]] {
250                 useSiteFS = true
251         } else if len(pathParts) >= 1 && strings.HasPrefix(pathParts[0], "c=") {
252                 // /c=ID[/PATH...]
253                 collectionID = parseCollectionIDFromURL(pathParts[0][2:])
254                 stripParts = 1
255         } else if len(pathParts) >= 2 && pathParts[0] == "collections" {
256                 if len(pathParts) >= 4 && pathParts[1] == "download" {
257                         // /collections/download/ID/TOKEN/PATH...
258                         collectionID = parseCollectionIDFromURL(pathParts[2])
259                         tokens = []string{pathParts[3]}
260                         stripParts = 4
261                         pathToken = true
262                 } else {
263                         // /collections/ID/PATH...
264                         collectionID = parseCollectionIDFromURL(pathParts[1])
265                         stripParts = 2
266                         // This path is only meant to work for public
267                         // data. Tokens provided with the request are
268                         // ignored.
269                         credentialsOK = false
270                         reasonNotAcceptingCredentials = "the '/collections/UUID/PATH' form only works for public data"
271                 }
272         }
273
274         if collectionID == "" && !useSiteFS {
275                 http.Error(w, notFoundMessage, http.StatusNotFound)
276                 return
277         }
278
279         forceReload := false
280         if cc := r.Header.Get("Cache-Control"); strings.Contains(cc, "no-cache") || strings.Contains(cc, "must-revalidate") {
281                 forceReload = true
282         }
283
284         if credentialsOK {
285                 reqTokens = auth.CredentialsFromRequest(r).Tokens
286         }
287
288         formToken := r.FormValue("api_token")
289         origin := r.Header.Get("Origin")
290         cors := origin != "" && !strings.HasSuffix(origin, "://"+r.Host)
291         safeAjax := cors && (r.Method == http.MethodGet || r.Method == http.MethodHead)
292         safeAttachment := attachment && r.URL.Query().Get("api_token") == ""
293         if formToken == "" {
294                 // No token to use or redact.
295         } else if safeAjax || safeAttachment {
296                 // If this is a cross-origin request, the URL won't
297                 // appear in the browser's address bar, so
298                 // substituting a clipboard-safe URL is pointless.
299                 // Redirect-with-cookie wouldn't work anyway, because
300                 // it's not safe to allow third-party use of our
301                 // cookie.
302                 //
303                 // If we're supplying an attachment, we don't need to
304                 // convert POST to GET to avoid the "really resubmit
305                 // form?" problem, so provided the token isn't
306                 // embedded in the URL, there's no reason to do
307                 // redirect-with-cookie in this case either.
308                 reqTokens = append(reqTokens, formToken)
309         } else if browserMethod[r.Method] {
310                 // If this is a page view, and the client provided a
311                 // token via query string or POST body, we must put
312                 // the token in an HttpOnly cookie, and redirect to an
313                 // equivalent URL with the query param redacted and
314                 // method = GET.
315                 h.seeOtherWithCookie(w, r, "", credentialsOK)
316                 return
317         }
318
319         if useSiteFS {
320                 h.serveSiteFS(w, r, reqTokens, credentialsOK, attachment)
321                 return
322         }
323
324         targetPath := pathParts[stripParts:]
325         if tokens == nil && len(targetPath) > 0 && strings.HasPrefix(targetPath[0], "t=") {
326                 // http://ID.example/t=TOKEN/PATH...
327                 // /c=ID/t=TOKEN/PATH...
328                 //
329                 // This form must only be used to pass scoped tokens
330                 // that give permission for a single collection. See
331                 // FormValue case above.
332                 tokens = []string{targetPath[0][2:]}
333                 pathToken = true
334                 targetPath = targetPath[1:]
335                 stripParts++
336         }
337
338         if tokens == nil {
339                 tokens = reqTokens
340                 if h.Cluster.Users.AnonymousUserToken != "" {
341                         tokens = append(tokens, h.Cluster.Users.AnonymousUserToken)
342                 }
343         }
344
345         if tokens == nil {
346                 if !credentialsOK {
347                         http.Error(w, fmt.Sprintf("Authorization tokens are not accepted here: %v, and no anonymous user token is configured.", reasonNotAcceptingCredentials), http.StatusUnauthorized)
348                 } else {
349                         http.Error(w, fmt.Sprintf("No authorization token in request, and no anonymous user token is configured."), http.StatusUnauthorized)
350                 }
351                 return
352         }
353
354         if len(targetPath) > 0 && targetPath[0] == "_" {
355                 // If a collection has a directory called "t=foo" or
356                 // "_", it can be served at
357                 // //collections.example/_/t=foo/ or
358                 // //collections.example/_/_/ respectively:
359                 // //collections.example/t=foo/ won't work because
360                 // t=foo will be interpreted as a token "foo".
361                 targetPath = targetPath[1:]
362                 stripParts++
363         }
364
365         arv := h.clientPool.Get()
366         if arv == nil {
367                 http.Error(w, "client pool error: "+h.clientPool.Err().Error(), http.StatusInternalServerError)
368                 return
369         }
370         defer h.clientPool.Put(arv)
371
372         var collection *arvados.Collection
373         var tokenUser *arvados.User
374         tokenResult := make(map[string]int)
375         for _, arv.ApiToken = range tokens {
376                 var err error
377                 collection, err = h.Cache.Get(arv, collectionID, forceReload)
378                 if err == nil {
379                         // Success
380                         break
381                 }
382                 if srvErr, ok := err.(arvadosclient.APIServerError); ok {
383                         switch srvErr.HttpStatusCode {
384                         case 404, 401:
385                                 // Token broken or insufficient to
386                                 // retrieve collection
387                                 tokenResult[arv.ApiToken] = srvErr.HttpStatusCode
388                                 continue
389                         }
390                 }
391                 // Something more serious is wrong
392                 http.Error(w, "cache error: "+err.Error(), http.StatusInternalServerError)
393                 return
394         }
395         if collection == nil {
396                 if pathToken || !credentialsOK {
397                         // Either the URL is a "secret sharing link"
398                         // that didn't work out (and asking the client
399                         // for additional credentials would just be
400                         // confusing), or we don't even accept
401                         // credentials at this path.
402                         http.Error(w, notFoundMessage, http.StatusNotFound)
403                         return
404                 }
405                 for _, t := range reqTokens {
406                         if tokenResult[t] == 404 {
407                                 // The client provided valid token(s), but the
408                                 // collection was not found.
409                                 http.Error(w, notFoundMessage, http.StatusNotFound)
410                                 return
411                         }
412                 }
413                 // The client's token was invalid (e.g., expired), or
414                 // the client didn't even provide one.  Propagate the
415                 // 401 to encourage the client to use a [different]
416                 // token.
417                 //
418                 // TODO(TC): This response would be confusing to
419                 // someone trying (anonymously) to download public
420                 // data that has been deleted.  Allow a referrer to
421                 // provide this context somehow?
422                 w.Header().Add("WWW-Authenticate", "Basic realm=\"collections\"")
423                 http.Error(w, unauthorizedMessage, http.StatusUnauthorized)
424                 return
425         }
426
427         kc, err := keepclient.MakeKeepClient(arv)
428         if err != nil {
429                 http.Error(w, "error setting up keep client: "+err.Error(), http.StatusInternalServerError)
430                 return
431         }
432         kc.RequestID = r.Header.Get("X-Request-Id")
433
434         var basename string
435         if len(targetPath) > 0 {
436                 basename = targetPath[len(targetPath)-1]
437         }
438         applyContentDispositionHdr(w, r, basename, attachment)
439
440         client := (&arvados.Client{
441                 APIHost:   arv.ApiServer,
442                 AuthToken: arv.ApiToken,
443                 Insecure:  arv.ApiInsecure,
444         }).WithRequestID(r.Header.Get("X-Request-Id"))
445
446         fs, err := collection.FileSystem(client, kc)
447         if err != nil {
448                 http.Error(w, "error creating collection filesystem: "+err.Error(), http.StatusInternalServerError)
449                 return
450         }
451
452         writefs, writeOK := fs.(arvados.CollectionFileSystem)
453         targetIsPDH := arvadosclient.PDHMatch(collectionID)
454         if (targetIsPDH || !writeOK) && writeMethod[r.Method] {
455                 http.Error(w, errReadOnly.Error(), http.StatusMethodNotAllowed)
456                 return
457         }
458
459         // Check configured permission
460         _, sess, err := h.Cache.GetSession(arv.ApiToken)
461         tokenUser, err = h.Cache.GetTokenUser(arv.ApiToken)
462
463         if webdavMethod[r.Method] {
464                 if !h.userPermittedToUploadOrDownload(r.Method, tokenUser) {
465                         http.Error(w, "Not permitted", http.StatusForbidden)
466                         return
467                 }
468                 h.logUploadOrDownload(r, sess.arvadosclient, nil, strings.Join(targetPath, "/"), collection, tokenUser)
469
470                 if writeMethod[r.Method] {
471                         // Save the collection only if/when all
472                         // webdav->filesystem operations succeed --
473                         // and send a 500 error if the modified
474                         // collection can't be saved.
475                         w = &updateOnSuccess{
476                                 ResponseWriter: w,
477                                 logger:         ctxlog.FromContext(r.Context()),
478                                 update: func() error {
479                                         return h.Cache.Update(client, *collection, writefs)
480                                 }}
481                 }
482                 h := webdav.Handler{
483                         Prefix: "/" + strings.Join(pathParts[:stripParts], "/"),
484                         FileSystem: &webdavFS{
485                                 collfs:        fs,
486                                 writing:       writeMethod[r.Method],
487                                 alwaysReadEOF: r.Method == "PROPFIND",
488                         },
489                         LockSystem: h.webdavLS,
490                         Logger: func(_ *http.Request, err error) {
491                                 if err != nil {
492                                         ctxlog.FromContext(r.Context()).WithError(err).Error("error reported by webdav handler")
493                                 }
494                         },
495                 }
496                 h.ServeHTTP(w, r)
497                 return
498         }
499
500         openPath := "/" + strings.Join(targetPath, "/")
501         f, err := fs.Open(openPath)
502         if os.IsNotExist(err) {
503                 // Requested non-existent path
504                 http.Error(w, notFoundMessage, http.StatusNotFound)
505                 return
506         } else if err != nil {
507                 // Some other (unexpected) error
508                 http.Error(w, "open: "+err.Error(), http.StatusInternalServerError)
509                 return
510         }
511         defer f.Close()
512         if stat, err := f.Stat(); err != nil {
513                 // Can't get Size/IsDir (shouldn't happen with a collectionFS!)
514                 http.Error(w, "stat: "+err.Error(), http.StatusInternalServerError)
515         } else if stat.IsDir() && !strings.HasSuffix(r.URL.Path, "/") {
516                 // If client requests ".../dirname", redirect to
517                 // ".../dirname/". This way, relative links in the
518                 // listing for "dirname" can always be "fnm", never
519                 // "dirname/fnm".
520                 h.seeOtherWithCookie(w, r, r.URL.Path+"/", credentialsOK)
521         } else if stat.IsDir() {
522                 h.serveDirectory(w, r, collection.Name, fs, openPath, true)
523         } else {
524                 if !h.userPermittedToUploadOrDownload(r.Method, tokenUser) {
525                         http.Error(w, "Not permitted", http.StatusForbidden)
526                         return
527                 }
528                 h.logUploadOrDownload(r, sess.arvadosclient, nil, strings.Join(targetPath, "/"), collection, tokenUser)
529
530                 http.ServeContent(w, r, basename, stat.ModTime(), f)
531                 if wrote := int64(w.WroteBodyBytes()); wrote != stat.Size() && w.WroteStatus() == http.StatusOK {
532                         // If we wrote fewer bytes than expected, it's
533                         // too late to change the real response code
534                         // or send an error message to the client, but
535                         // at least we can try to put some useful
536                         // debugging info in the logs.
537                         n, err := f.Read(make([]byte, 1024))
538                         ctxlog.FromContext(r.Context()).Errorf("stat.Size()==%d but only wrote %d bytes; read(1024) returns %d, %v", stat.Size(), wrote, n, err)
539                 }
540         }
541 }
542
543 func (h *handler) getClients(reqID, token string) (arv *arvadosclient.ArvadosClient, kc *keepclient.KeepClient, client *arvados.Client, release func(), err error) {
544         arv = h.clientPool.Get()
545         if arv == nil {
546                 err = h.clientPool.Err()
547                 return
548         }
549         release = func() { h.clientPool.Put(arv) }
550         arv.ApiToken = token
551         kc, err = keepclient.MakeKeepClient(arv)
552         if err != nil {
553                 release()
554                 return
555         }
556         kc.RequestID = reqID
557         client = (&arvados.Client{
558                 APIHost:   arv.ApiServer,
559                 AuthToken: arv.ApiToken,
560                 Insecure:  arv.ApiInsecure,
561         }).WithRequestID(reqID)
562         return
563 }
564
565 func (h *handler) serveSiteFS(w http.ResponseWriter, r *http.Request, tokens []string, credentialsOK, attachment bool) {
566         if len(tokens) == 0 {
567                 w.Header().Add("WWW-Authenticate", "Basic realm=\"collections\"")
568                 http.Error(w, unauthorizedMessage, http.StatusUnauthorized)
569                 return
570         }
571         if writeMethod[r.Method] {
572                 http.Error(w, errReadOnly.Error(), http.StatusMethodNotAllowed)
573                 return
574         }
575
576         fs, sess, err := h.Cache.GetSession(tokens[0])
577         if err != nil {
578                 http.Error(w, err.Error(), http.StatusInternalServerError)
579                 return
580         }
581         fs.ForwardSlashNameSubstitution(h.Cluster.Collections.ForwardSlashNameSubstitution)
582         f, err := fs.Open(r.URL.Path)
583         if os.IsNotExist(err) {
584                 http.Error(w, err.Error(), http.StatusNotFound)
585                 return
586         } else if err != nil {
587                 http.Error(w, err.Error(), http.StatusInternalServerError)
588                 return
589         }
590         defer f.Close()
591         if fi, err := f.Stat(); err == nil && fi.IsDir() && r.Method == "GET" {
592                 if !strings.HasSuffix(r.URL.Path, "/") {
593                         h.seeOtherWithCookie(w, r, r.URL.Path+"/", credentialsOK)
594                 } else {
595                         h.serveDirectory(w, r, fi.Name(), fs, r.URL.Path, false)
596                 }
597                 return
598         }
599
600         tokenUser, err := h.Cache.GetTokenUser(tokens[0])
601         if !h.userPermittedToUploadOrDownload(r.Method, tokenUser) {
602                 http.Error(w, "Not permitted", http.StatusForbidden)
603                 return
604         }
605         h.logUploadOrDownload(r, sess.arvadosclient, fs, r.URL.Path, nil, tokenUser)
606
607         if r.Method == "GET" {
608                 _, basename := filepath.Split(r.URL.Path)
609                 applyContentDispositionHdr(w, r, basename, attachment)
610         }
611         wh := webdav.Handler{
612                 Prefix: "/",
613                 FileSystem: &webdavFS{
614                         collfs:        fs,
615                         writing:       writeMethod[r.Method],
616                         alwaysReadEOF: r.Method == "PROPFIND",
617                 },
618                 LockSystem: h.webdavLS,
619                 Logger: func(_ *http.Request, err error) {
620                         if err != nil {
621                                 ctxlog.FromContext(r.Context()).WithError(err).Error("error reported by webdav handler")
622                         }
623                 },
624         }
625         wh.ServeHTTP(w, r)
626 }
627
628 var dirListingTemplate = `<!DOCTYPE HTML>
629 <HTML><HEAD>
630   <META name="robots" content="NOINDEX">
631   <TITLE>{{ .CollectionName }}</TITLE>
632   <STYLE type="text/css">
633     body {
634       margin: 1.5em;
635     }
636     pre {
637       background-color: #D9EDF7;
638       border-radius: .25em;
639       padding: .75em;
640       overflow: auto;
641     }
642     .footer p {
643       font-size: 82%;
644     }
645     ul {
646       padding: 0;
647     }
648     ul li {
649       font-family: monospace;
650       list-style: none;
651     }
652   </STYLE>
653 </HEAD>
654 <BODY>
655
656 <H1>{{ .CollectionName }}</H1>
657
658 <P>This collection of data files is being shared with you through
659 Arvados.  You can download individual files listed below.  To download
660 the entire directory tree with wget, try:</P>
661
662 <PRE>$ wget --mirror --no-parent --no-host --cut-dirs={{ .StripParts }} https://{{ .Request.Host }}{{ .Request.URL.Path }}</PRE>
663
664 <H2>File Listing</H2>
665
666 {{if .Files}}
667 <UL>
668 {{range .Files}}
669 {{if .IsDir }}
670   <LI>{{" " | printf "%15s  " | nbsp}}<A href="{{print "./" .Name}}/">{{.Name}}/</A></LI>
671 {{else}}
672   <LI>{{.Size | printf "%15d  " | nbsp}}<A href="{{print "./" .Name}}">{{.Name}}</A></LI>
673 {{end}}
674 {{end}}
675 </UL>
676 {{else}}
677 <P>(No files; this collection is empty.)</P>
678 {{end}}
679
680 <HR noshade>
681 <DIV class="footer">
682   <P>
683     About Arvados:
684     Arvados is a free and open source software bioinformatics platform.
685     To learn more, visit arvados.org.
686     Arvados is not responsible for the files listed on this page.
687   </P>
688 </DIV>
689
690 </BODY>
691 `
692
693 type fileListEnt struct {
694         Name  string
695         Size  int64
696         IsDir bool
697 }
698
699 func (h *handler) serveDirectory(w http.ResponseWriter, r *http.Request, collectionName string, fs http.FileSystem, base string, recurse bool) {
700         var files []fileListEnt
701         var walk func(string) error
702         if !strings.HasSuffix(base, "/") {
703                 base = base + "/"
704         }
705         walk = func(path string) error {
706                 dirname := base + path
707                 if dirname != "/" {
708                         dirname = strings.TrimSuffix(dirname, "/")
709                 }
710                 d, err := fs.Open(dirname)
711                 if err != nil {
712                         return err
713                 }
714                 ents, err := d.Readdir(-1)
715                 if err != nil {
716                         return err
717                 }
718                 for _, ent := range ents {
719                         if recurse && ent.IsDir() {
720                                 err = walk(path + ent.Name() + "/")
721                                 if err != nil {
722                                         return err
723                                 }
724                         } else {
725                                 files = append(files, fileListEnt{
726                                         Name:  path + ent.Name(),
727                                         Size:  ent.Size(),
728                                         IsDir: ent.IsDir(),
729                                 })
730                         }
731                 }
732                 return nil
733         }
734         if err := walk(""); err != nil {
735                 http.Error(w, "error getting directory listing: "+err.Error(), http.StatusInternalServerError)
736                 return
737         }
738
739         funcs := template.FuncMap{
740                 "nbsp": func(s string) template.HTML {
741                         return template.HTML(strings.Replace(s, " ", "&nbsp;", -1))
742                 },
743         }
744         tmpl, err := template.New("dir").Funcs(funcs).Parse(dirListingTemplate)
745         if err != nil {
746                 http.Error(w, "error parsing template: "+err.Error(), http.StatusInternalServerError)
747                 return
748         }
749         sort.Slice(files, func(i, j int) bool {
750                 return files[i].Name < files[j].Name
751         })
752         w.WriteHeader(http.StatusOK)
753         tmpl.Execute(w, map[string]interface{}{
754                 "CollectionName": collectionName,
755                 "Files":          files,
756                 "Request":        r,
757                 "StripParts":     strings.Count(strings.TrimRight(r.URL.Path, "/"), "/"),
758         })
759 }
760
761 func applyContentDispositionHdr(w http.ResponseWriter, r *http.Request, filename string, isAttachment bool) {
762         disposition := "inline"
763         if isAttachment {
764                 disposition = "attachment"
765         }
766         if strings.ContainsRune(r.RequestURI, '?') {
767                 // Help the UA realize that the filename is just
768                 // "filename.txt", not
769                 // "filename.txt?disposition=attachment".
770                 //
771                 // TODO(TC): Follow advice at RFC 6266 appendix D
772                 disposition += "; filename=" + strconv.QuoteToASCII(filename)
773         }
774         if disposition != "inline" {
775                 w.Header().Set("Content-Disposition", disposition)
776         }
777 }
778
779 func (h *handler) seeOtherWithCookie(w http.ResponseWriter, r *http.Request, location string, credentialsOK bool) {
780         if formToken := r.FormValue("api_token"); formToken != "" {
781                 if !credentialsOK {
782                         // It is not safe to copy the provided token
783                         // into a cookie unless the current vhost
784                         // (origin) serves only a single collection or
785                         // we are in TrustAllContent mode.
786                         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)
787                         return
788                 }
789
790                 // The HttpOnly flag is necessary to prevent
791                 // JavaScript code (included in, or loaded by, a page
792                 // in the collection being served) from employing the
793                 // user's token beyond reading other files in the same
794                 // domain, i.e., same collection.
795                 //
796                 // The 303 redirect is necessary in the case of a GET
797                 // request to avoid exposing the token in the Location
798                 // bar, and in the case of a POST request to avoid
799                 // raising warnings when the user refreshes the
800                 // resulting page.
801                 http.SetCookie(w, &http.Cookie{
802                         Name:     "arvados_api_token",
803                         Value:    auth.EncodeTokenCookie([]byte(formToken)),
804                         Path:     "/",
805                         HttpOnly: true,
806                         SameSite: http.SameSiteLaxMode,
807                 })
808         }
809
810         // Propagate query parameters (except api_token) from
811         // the original request.
812         redirQuery := r.URL.Query()
813         redirQuery.Del("api_token")
814
815         u := r.URL
816         if location != "" {
817                 newu, err := u.Parse(location)
818                 if err != nil {
819                         http.Error(w, "error resolving redirect target: "+err.Error(), http.StatusInternalServerError)
820                         return
821                 }
822                 u = newu
823         }
824         redir := (&url.URL{
825                 Scheme:   r.URL.Scheme,
826                 Host:     r.Host,
827                 Path:     u.Path,
828                 RawQuery: redirQuery.Encode(),
829         }).String()
830
831         w.Header().Add("Location", redir)
832         w.WriteHeader(http.StatusSeeOther)
833         io.WriteString(w, `<A href="`)
834         io.WriteString(w, html.EscapeString(redir))
835         io.WriteString(w, `">Continue</A>`)
836 }
837
838 func (h *handler) userPermittedToUploadOrDownload(method string, tokenUser *arvados.User) bool {
839         var permitDownload bool
840         var permitUpload bool
841         if tokenUser != nil && tokenUser.IsAdmin {
842                 permitUpload = h.Cluster.Collections.WebDAVPermission.Admin.Upload
843                 permitDownload = h.Cluster.Collections.WebDAVPermission.Admin.Download
844         } else {
845                 permitUpload = h.Cluster.Collections.WebDAVPermission.User.Upload
846                 permitDownload = h.Cluster.Collections.WebDAVPermission.User.Download
847         }
848         if (method == "PUT" || method == "POST") && !permitUpload {
849                 // Disallow operations that upload new files.
850                 // Permit webdav operations that move existing files around.
851                 return false
852         } else if method == "GET" && !permitDownload {
853                 // Disallow downloading file contents.
854                 // Permit webdav operations like PROPFIND that retrieve metadata
855                 // but not file contents.
856                 return false
857         }
858         return true
859 }
860
861 func (h *handler) logUploadOrDownload(
862         r *http.Request,
863         client *arvadosclient.ArvadosClient,
864         fs arvados.CustomFileSystem,
865         filepath string,
866         collection *arvados.Collection,
867         user *arvados.User) {
868
869         log := ctxlog.FromContext(r.Context())
870         props := make(map[string]string)
871         props["reqPath"] = r.URL.Path
872         var useruuid string
873         if user != nil {
874                 log = log.WithField("user_uuid", user.UUID).
875                         WithField("user_full_name", user.FullName)
876                 useruuid = user.UUID
877         } else {
878                 useruuid = fmt.Sprintf("%s-tpzed-anonymouspublic", h.Cluster.ClusterID)
879         }
880         if collection == nil && fs != nil {
881                 collection, filepath = h.determineCollection(fs, filepath)
882         }
883         if collection != nil {
884                 log = log.WithField("collection_uuid", collection.UUID).
885                         WithField("collection_file_path", filepath)
886                 props["collection_uuid"] = collection.UUID
887                 props["collection_file_path"] = filepath
888                 // h.determineCollection populates the collection_uuid prop with the PDH, if
889                 // this collection is being accessed via PDH. In that case, blank the
890                 // collection_uuid field so that consumers of the log entries can rely on it
891                 // being a UUID, or blank. The PDH remains available via the
892                 // portable_data_hash property.
893                 if props["collection_uuid"] == collection.PortableDataHash {
894                         props["collection_uuid"] = ""
895                 }
896         }
897         if r.Method == "PUT" || r.Method == "POST" {
898                 log.Info("File upload")
899                 if h.Cluster.Collections.WebDAVLogEvents {
900                         go func() {
901                                 lr := arvadosclient.Dict{"log": arvadosclient.Dict{
902                                         "object_uuid": useruuid,
903                                         "event_type":  "file_upload",
904                                         "properties":  props}}
905                                 err := client.Create("logs", lr, nil)
906                                 if err != nil {
907                                         log.WithError(err).Error("Failed to create upload log event on API server")
908                                 }
909                         }()
910                 }
911         } else if r.Method == "GET" {
912                 if collection != nil && collection.PortableDataHash != "" {
913                         log = log.WithField("portable_data_hash", collection.PortableDataHash)
914                         props["portable_data_hash"] = collection.PortableDataHash
915                 }
916                 log.Info("File download")
917                 if h.Cluster.Collections.WebDAVLogEvents {
918                         go func() {
919                                 lr := arvadosclient.Dict{"log": arvadosclient.Dict{
920                                         "object_uuid": useruuid,
921                                         "event_type":  "file_download",
922                                         "properties":  props}}
923                                 err := client.Create("logs", lr, nil)
924                                 if err != nil {
925                                         log.WithError(err).Error("Failed to create download log event on API server")
926                                 }
927                         }()
928                 }
929         }
930 }
931
932 func (h *handler) determineCollection(fs arvados.CustomFileSystem, path string) (*arvados.Collection, string) {
933         segments := strings.Split(path, "/")
934         var i int
935         for i = 0; i < len(segments); i++ {
936                 dir := append([]string{}, segments[0:i]...)
937                 dir = append(dir, ".arvados#collection")
938                 f, err := fs.OpenFile(strings.Join(dir, "/"), os.O_RDONLY, 0)
939                 if f != nil {
940                         defer f.Close()
941                 }
942                 if err != nil {
943                         if !os.IsNotExist(err) {
944                                 return nil, ""
945                         }
946                         continue
947                 }
948                 // err is nil so we found it.
949                 decoder := json.NewDecoder(f)
950                 var collection arvados.Collection
951                 err = decoder.Decode(&collection)
952                 if err != nil {
953                         return nil, ""
954                 }
955                 return &collection, strings.Join(segments[i:], "/")
956         }
957         return nil, ""
958 }