13497: Use X-Forwarded-Proto as scheme for keep-web redirect URLs.
[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 main
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.curoverse.com/arvados.git/sdk/go/arvados"
23         "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
24         "git.curoverse.com/arvados.git/sdk/go/auth"
25         "git.curoverse.com/arvados.git/sdk/go/health"
26         "git.curoverse.com/arvados.git/sdk/go/httpserver"
27         "git.curoverse.com/arvados.git/sdk/go/keepclient"
28         log "github.com/Sirupsen/logrus"
29         "golang.org/x/net/webdav"
30 )
31
32 type handler struct {
33         Config        *Config
34         clientPool    *arvadosclient.ClientPool
35         setupOnce     sync.Once
36         healthHandler http.Handler
37         webdavLS      webdav.LockSystem
38 }
39
40 // parseCollectionIDFromDNSName returns a UUID or PDH if s begins with
41 // a UUID or URL-encoded PDH; otherwise "".
42 func parseCollectionIDFromDNSName(s string) string {
43         // Strip domain.
44         if i := strings.IndexRune(s, '.'); i >= 0 {
45                 s = s[:i]
46         }
47         // Names like {uuid}--collections.example.com serve the same
48         // purpose as {uuid}.collections.example.com but can reduce
49         // cost/effort of using [additional] wildcard certificates.
50         if i := strings.Index(s, "--"); i >= 0 {
51                 s = s[:i]
52         }
53         if arvadosclient.UUIDMatch(s) {
54                 return s
55         }
56         if pdh := strings.Replace(s, "-", "+", 1); arvadosclient.PDHMatch(pdh) {
57                 return pdh
58         }
59         return ""
60 }
61
62 var urlPDHDecoder = strings.NewReplacer(" ", "+", "-", "+")
63
64 // parseCollectionIDFromURL returns a UUID or PDH if s is a UUID or a
65 // PDH (even if it is a PDH with "+" replaced by " " or "-");
66 // otherwise "".
67 func parseCollectionIDFromURL(s string) string {
68         if arvadosclient.UUIDMatch(s) {
69                 return s
70         }
71         if pdh := urlPDHDecoder.Replace(s); arvadosclient.PDHMatch(pdh) {
72                 return pdh
73         }
74         return ""
75 }
76
77 func (h *handler) setup() {
78         h.clientPool = arvadosclient.MakeClientPool()
79
80         keepclient.RefreshServiceDiscoveryOnSIGHUP()
81
82         h.healthHandler = &health.Handler{
83                 Token:  h.Config.ManagementToken,
84                 Prefix: "/_health/",
85         }
86
87         // Even though we don't accept LOCK requests, every webdav
88         // handler must have a non-nil LockSystem.
89         h.webdavLS = &noLockSystem{}
90 }
91
92 func (h *handler) serveStatus(w http.ResponseWriter, r *http.Request) {
93         status := struct {
94                 cacheStats
95                 Version string
96         }{
97                 cacheStats: h.Config.Cache.Stats(),
98                 Version:    version,
99         }
100         json.NewEncoder(w).Encode(status)
101 }
102
103 // updateOnSuccess wraps httpserver.ResponseWriter. If the handler
104 // sends an HTTP header indicating success, updateOnSuccess first
105 // calls the provided update func. If the update func fails, a 500
106 // response is sent, and the status code and body sent by the handler
107 // are ignored (all response writes return the update error).
108 type updateOnSuccess struct {
109         httpserver.ResponseWriter
110         update     func() error
111         sentHeader bool
112         err        error
113 }
114
115 func (uos *updateOnSuccess) Write(p []byte) (int, error) {
116         if !uos.sentHeader {
117                 uos.WriteHeader(http.StatusOK)
118         }
119         if uos.err != nil {
120                 return 0, uos.err
121         }
122         return uos.ResponseWriter.Write(p)
123 }
124
125 func (uos *updateOnSuccess) WriteHeader(code int) {
126         if !uos.sentHeader {
127                 uos.sentHeader = true
128                 if code >= 200 && code < 400 {
129                         if uos.err = uos.update(); uos.err != nil {
130                                 code := http.StatusInternalServerError
131                                 if err, ok := uos.err.(*arvados.TransactionError); ok {
132                                         code = err.StatusCode
133                                 }
134                                 log.Printf("update() changes response to HTTP %d: %T %q", code, uos.err, uos.err)
135                                 http.Error(uos.ResponseWriter, uos.err.Error(), code)
136                                 return
137                         }
138                 }
139         }
140         uos.ResponseWriter.WriteHeader(code)
141 }
142
143 var (
144         writeMethod = map[string]bool{
145                 "COPY":   true,
146                 "DELETE": true,
147                 "MKCOL":  true,
148                 "MOVE":   true,
149                 "PUT":    true,
150                 "RMCOL":  true,
151         }
152         webdavMethod = map[string]bool{
153                 "COPY":     true,
154                 "DELETE":   true,
155                 "MKCOL":    true,
156                 "MOVE":     true,
157                 "OPTIONS":  true,
158                 "PROPFIND": true,
159                 "PUT":      true,
160                 "RMCOL":    true,
161         }
162         browserMethod = map[string]bool{
163                 "GET":  true,
164                 "HEAD": true,
165                 "POST": true,
166         }
167         // top-level dirs to serve with siteFS
168         siteFSDir = map[string]bool{
169                 "":      true, // root directory
170                 "by_id": true,
171                 "users": true,
172         }
173 )
174
175 // ServeHTTP implements http.Handler.
176 func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
177         h.setupOnce.Do(h.setup)
178
179         var statusCode = 0
180         var statusText string
181
182         remoteAddr := r.RemoteAddr
183         if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
184                 remoteAddr = xff + "," + remoteAddr
185         }
186         if xfp := r.Header.Get("X-Forwarded-Proto"); xfp != "" && xfp != "http" {
187                 r.URL.Scheme = xfp
188         }
189
190         w := httpserver.WrapResponseWriter(wOrig)
191         defer func() {
192                 if statusCode == 0 {
193                         statusCode = w.WroteStatus()
194                 } else if w.WroteStatus() == 0 {
195                         w.WriteHeader(statusCode)
196                 } else if w.WroteStatus() != statusCode {
197                         log.WithField("RequestID", r.Header.Get("X-Request-Id")).Warn(
198                                 fmt.Sprintf("Our status changed from %d to %d after we sent headers", w.WroteStatus(), statusCode))
199                 }
200                 if statusText == "" {
201                         statusText = http.StatusText(statusCode)
202                 }
203         }()
204
205         if strings.HasPrefix(r.URL.Path, "/_health/") && r.Method == "GET" {
206                 h.healthHandler.ServeHTTP(w, r)
207                 return
208         }
209
210         if method := r.Header.Get("Access-Control-Request-Method"); method != "" && r.Method == "OPTIONS" {
211                 if !browserMethod[method] && !webdavMethod[method] {
212                         statusCode = http.StatusMethodNotAllowed
213                         return
214                 }
215                 w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Range")
216                 w.Header().Set("Access-Control-Allow-Methods", "COPY, DELETE, GET, MKCOL, MOVE, OPTIONS, POST, PROPFIND, PUT, RMCOL")
217                 w.Header().Set("Access-Control-Allow-Origin", "*")
218                 w.Header().Set("Access-Control-Max-Age", "86400")
219                 statusCode = http.StatusOK
220                 return
221         }
222
223         if !browserMethod[r.Method] && !webdavMethod[r.Method] {
224                 statusCode, statusText = http.StatusMethodNotAllowed, r.Method
225                 return
226         }
227
228         if r.Header.Get("Origin") != "" {
229                 // Allow simple cross-origin requests without user
230                 // credentials ("user credentials" as defined by CORS,
231                 // i.e., cookies, HTTP authentication, and client-side
232                 // SSL certificates. See
233                 // http://www.w3.org/TR/cors/#user-credentials).
234                 w.Header().Set("Access-Control-Allow-Origin", "*")
235                 w.Header().Set("Access-Control-Expose-Headers", "Content-Range")
236         }
237
238         pathParts := strings.Split(r.URL.Path[1:], "/")
239
240         var stripParts int
241         var collectionID string
242         var tokens []string
243         var reqTokens []string
244         var pathToken bool
245         var attachment bool
246         var useSiteFS bool
247         credentialsOK := h.Config.TrustAllContent
248
249         if r.Host != "" && r.Host == h.Config.AttachmentOnlyHost {
250                 credentialsOK = true
251                 attachment = true
252         } else if r.FormValue("disposition") == "attachment" {
253                 attachment = true
254         }
255
256         if collectionID = parseCollectionIDFromDNSName(r.Host); collectionID != "" {
257                 // http://ID.collections.example/PATH...
258                 credentialsOK = true
259         } else if r.URL.Path == "/status.json" {
260                 h.serveStatus(w, r)
261                 return
262         } else if siteFSDir[pathParts[0]] {
263                 useSiteFS = true
264         } else if len(pathParts) >= 1 && strings.HasPrefix(pathParts[0], "c=") {
265                 // /c=ID[/PATH...]
266                 collectionID = parseCollectionIDFromURL(pathParts[0][2:])
267                 stripParts = 1
268         } else if len(pathParts) >= 2 && pathParts[0] == "collections" {
269                 if len(pathParts) >= 4 && pathParts[1] == "download" {
270                         // /collections/download/ID/TOKEN/PATH...
271                         collectionID = parseCollectionIDFromURL(pathParts[2])
272                         tokens = []string{pathParts[3]}
273                         stripParts = 4
274                         pathToken = true
275                 } else {
276                         // /collections/ID/PATH...
277                         collectionID = parseCollectionIDFromURL(pathParts[1])
278                         tokens = h.Config.AnonymousTokens
279                         stripParts = 2
280                 }
281         }
282
283         if collectionID == "" && !useSiteFS {
284                 statusCode = http.StatusNotFound
285                 return
286         }
287
288         forceReload := false
289         if cc := r.Header.Get("Cache-Control"); strings.Contains(cc, "no-cache") || strings.Contains(cc, "must-revalidate") {
290                 forceReload = true
291         }
292
293         formToken := r.FormValue("api_token")
294         if formToken != "" && r.Header.Get("Origin") != "" && attachment && r.URL.Query().Get("api_token") == "" {
295                 // The client provided an explicit token in the POST
296                 // body. The Origin header indicates this *might* be
297                 // an AJAX request, in which case redirect-with-cookie
298                 // won't work: we should just serve the content in the
299                 // POST response. This is safe because:
300                 //
301                 // * We're supplying an attachment, not inline
302                 //   content, so we don't need to convert the POST to
303                 //   a GET and avoid the "really resubmit form?"
304                 //   problem.
305                 //
306                 // * The token isn't embedded in the URL, so we don't
307                 //   need to worry about bookmarks and copy/paste.
308                 tokens = append(tokens, formToken)
309         } else if formToken != "" && browserMethod[r.Method] {
310                 // The client provided an explicit token in the query
311                 // string, or a form in POST body. We must put the
312                 // token in an HttpOnly cookie, and redirect to the
313                 // same URL with the query param redacted and method =
314                 // GET.
315                 h.seeOtherWithCookie(w, r, "", credentialsOK)
316                 return
317         }
318
319         if useSiteFS {
320                 if tokens == nil {
321                         tokens = auth.NewCredentialsFromHTTPRequest(r).Tokens
322                 }
323                 h.serveSiteFS(w, r, tokens, credentialsOK, attachment)
324                 return
325         }
326
327         targetPath := pathParts[stripParts:]
328         if tokens == nil && len(targetPath) > 0 && strings.HasPrefix(targetPath[0], "t=") {
329                 // http://ID.example/t=TOKEN/PATH...
330                 // /c=ID/t=TOKEN/PATH...
331                 //
332                 // This form must only be used to pass scoped tokens
333                 // that give permission for a single collection. See
334                 // FormValue case above.
335                 tokens = []string{targetPath[0][2:]}
336                 pathToken = true
337                 targetPath = targetPath[1:]
338                 stripParts++
339         }
340
341         if tokens == nil {
342                 if credentialsOK {
343                         reqTokens = auth.NewCredentialsFromHTTPRequest(r).Tokens
344                 }
345                 tokens = append(reqTokens, h.Config.AnonymousTokens...)
346         }
347
348         if len(targetPath) > 0 && targetPath[0] == "_" {
349                 // If a collection has a directory called "t=foo" or
350                 // "_", it can be served at
351                 // //collections.example/_/t=foo/ or
352                 // //collections.example/_/_/ respectively:
353                 // //collections.example/t=foo/ won't work because
354                 // t=foo will be interpreted as a token "foo".
355                 targetPath = targetPath[1:]
356                 stripParts++
357         }
358
359         arv := h.clientPool.Get()
360         if arv == nil {
361                 statusCode, statusText = http.StatusInternalServerError, "Pool failed: "+h.clientPool.Err().Error()
362                 return
363         }
364         defer h.clientPool.Put(arv)
365
366         var collection *arvados.Collection
367         tokenResult := make(map[string]int)
368         for _, arv.ApiToken = range tokens {
369                 var err error
370                 collection, err = h.Config.Cache.Get(arv, collectionID, forceReload)
371                 if err == nil {
372                         // Success
373                         break
374                 }
375                 if srvErr, ok := err.(arvadosclient.APIServerError); ok {
376                         switch srvErr.HttpStatusCode {
377                         case 404, 401:
378                                 // Token broken or insufficient to
379                                 // retrieve collection
380                                 tokenResult[arv.ApiToken] = srvErr.HttpStatusCode
381                                 continue
382                         }
383                 }
384                 // Something more serious is wrong
385                 statusCode, statusText = http.StatusInternalServerError, err.Error()
386                 return
387         }
388         if collection == nil {
389                 if pathToken || !credentialsOK {
390                         // Either the URL is a "secret sharing link"
391                         // that didn't work out (and asking the client
392                         // for additional credentials would just be
393                         // confusing), or we don't even accept
394                         // credentials at this path.
395                         statusCode = http.StatusNotFound
396                         return
397                 }
398                 for _, t := range reqTokens {
399                         if tokenResult[t] == 404 {
400                                 // The client provided valid token(s), but the
401                                 // collection was not found.
402                                 statusCode = http.StatusNotFound
403                                 return
404                         }
405                 }
406                 // The client's token was invalid (e.g., expired), or
407                 // the client didn't even provide one.  Propagate the
408                 // 401 to encourage the client to use a [different]
409                 // token.
410                 //
411                 // TODO(TC): This response would be confusing to
412                 // someone trying (anonymously) to download public
413                 // data that has been deleted.  Allow a referrer to
414                 // provide this context somehow?
415                 w.Header().Add("WWW-Authenticate", "Basic realm=\"collections\"")
416                 statusCode = http.StatusUnauthorized
417                 return
418         }
419
420         kc, err := keepclient.MakeKeepClient(arv)
421         if err != nil {
422                 statusCode, statusText = http.StatusInternalServerError, err.Error()
423                 return
424         }
425         kc.RequestID = r.Header.Get("X-Request-Id")
426
427         var basename string
428         if len(targetPath) > 0 {
429                 basename = targetPath[len(targetPath)-1]
430         }
431         applyContentDispositionHdr(w, r, basename, attachment)
432
433         client := (&arvados.Client{
434                 APIHost:   arv.ApiServer,
435                 AuthToken: arv.ApiToken,
436                 Insecure:  arv.ApiInsecure,
437         }).WithRequestID(r.Header.Get("X-Request-Id"))
438
439         fs, err := collection.FileSystem(client, kc)
440         if err != nil {
441                 statusCode, statusText = http.StatusInternalServerError, err.Error()
442                 return
443         }
444
445         writefs, writeOK := fs.(arvados.CollectionFileSystem)
446         targetIsPDH := arvadosclient.PDHMatch(collectionID)
447         if (targetIsPDH || !writeOK) && writeMethod[r.Method] {
448                 statusCode, statusText = http.StatusMethodNotAllowed, errReadOnly.Error()
449                 return
450         }
451
452         if webdavMethod[r.Method] {
453                 if writeMethod[r.Method] {
454                         // Save the collection only if/when all
455                         // webdav->filesystem operations succeed --
456                         // and send a 500 error if the modified
457                         // collection can't be saved.
458                         w = &updateOnSuccess{
459                                 ResponseWriter: w,
460                                 update: func() error {
461                                         return h.Config.Cache.Update(client, *collection, writefs)
462                                 }}
463                 }
464                 h := webdav.Handler{
465                         Prefix: "/" + strings.Join(pathParts[:stripParts], "/"),
466                         FileSystem: &webdavFS{
467                                 collfs:        fs,
468                                 writing:       writeMethod[r.Method],
469                                 alwaysReadEOF: r.Method == "PROPFIND",
470                         },
471                         LockSystem: h.webdavLS,
472                         Logger: func(_ *http.Request, err error) {
473                                 if err != nil {
474                                         log.Printf("error from webdav handler: %q", err)
475                                 }
476                         },
477                 }
478                 h.ServeHTTP(w, r)
479                 return
480         }
481
482         openPath := "/" + strings.Join(targetPath, "/")
483         if f, err := fs.Open(openPath); os.IsNotExist(err) {
484                 // Requested non-existent path
485                 statusCode = http.StatusNotFound
486         } else if err != nil {
487                 // Some other (unexpected) error
488                 statusCode, statusText = http.StatusInternalServerError, err.Error()
489         } else if stat, err := f.Stat(); err != nil {
490                 // Can't get Size/IsDir (shouldn't happen with a collectionFS!)
491                 statusCode, statusText = http.StatusInternalServerError, err.Error()
492         } else if stat.IsDir() && !strings.HasSuffix(r.URL.Path, "/") {
493                 // If client requests ".../dirname", redirect to
494                 // ".../dirname/". This way, relative links in the
495                 // listing for "dirname" can always be "fnm", never
496                 // "dirname/fnm".
497                 h.seeOtherWithCookie(w, r, r.URL.Path+"/", credentialsOK)
498         } else if stat.IsDir() {
499                 h.serveDirectory(w, r, collection.Name, fs, openPath, true)
500         } else {
501                 http.ServeContent(w, r, basename, stat.ModTime(), f)
502                 if r.Header.Get("Range") == "" && int64(w.WroteBodyBytes()) != stat.Size() {
503                         // If we wrote fewer bytes than expected, it's
504                         // too late to change the real response code
505                         // or send an error message to the client, but
506                         // at least we can try to put some useful
507                         // debugging info in the logs.
508                         n, err := f.Read(make([]byte, 1024))
509                         statusCode, statusText = http.StatusInternalServerError, fmt.Sprintf("f.Size()==%d but only wrote %d bytes; read(1024) returns %d, %s", stat.Size(), w.WroteBodyBytes(), n, err)
510
511                 }
512         }
513 }
514
515 func (h *handler) serveSiteFS(w http.ResponseWriter, r *http.Request, tokens []string, credentialsOK, attachment bool) {
516         if len(tokens) == 0 {
517                 w.Header().Add("WWW-Authenticate", "Basic realm=\"collections\"")
518                 http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
519                 return
520         }
521         if writeMethod[r.Method] {
522                 http.Error(w, errReadOnly.Error(), http.StatusMethodNotAllowed)
523                 return
524         }
525         arv := h.clientPool.Get()
526         if arv == nil {
527                 http.Error(w, "Pool failed: "+h.clientPool.Err().Error(), http.StatusInternalServerError)
528                 return
529         }
530         defer h.clientPool.Put(arv)
531         arv.ApiToken = tokens[0]
532
533         kc, err := keepclient.MakeKeepClient(arv)
534         if err != nil {
535                 http.Error(w, err.Error(), http.StatusInternalServerError)
536                 return
537         }
538         kc.RequestID = r.Header.Get("X-Request-Id")
539         client := (&arvados.Client{
540                 APIHost:   arv.ApiServer,
541                 AuthToken: arv.ApiToken,
542                 Insecure:  arv.ApiInsecure,
543         }).WithRequestID(r.Header.Get("X-Request-Id"))
544         fs := client.SiteFileSystem(kc)
545         f, err := fs.Open(r.URL.Path)
546         if os.IsNotExist(err) {
547                 http.Error(w, err.Error(), http.StatusNotFound)
548                 return
549         } else if err != nil {
550                 http.Error(w, err.Error(), http.StatusInternalServerError)
551                 return
552         }
553         defer f.Close()
554         if fi, err := f.Stat(); err == nil && fi.IsDir() && r.Method == "GET" {
555                 if !strings.HasSuffix(r.URL.Path, "/") {
556                         h.seeOtherWithCookie(w, r, r.URL.Path+"/", credentialsOK)
557                 } else {
558                         h.serveDirectory(w, r, fi.Name(), fs, r.URL.Path, false)
559                 }
560                 return
561         }
562         if r.Method == "GET" {
563                 _, basename := filepath.Split(r.URL.Path)
564                 applyContentDispositionHdr(w, r, basename, attachment)
565         }
566         wh := webdav.Handler{
567                 Prefix: "/",
568                 FileSystem: &webdavFS{
569                         collfs:        fs,
570                         writing:       writeMethod[r.Method],
571                         alwaysReadEOF: r.Method == "PROPFIND",
572                 },
573                 LockSystem: h.webdavLS,
574                 Logger: func(_ *http.Request, err error) {
575                         if err != nil {
576                                 log.Printf("error from webdav handler: %q", err)
577                         }
578                 },
579         }
580         wh.ServeHTTP(w, r)
581 }
582
583 var dirListingTemplate = `<!DOCTYPE HTML>
584 <HTML><HEAD>
585   <META name="robots" content="NOINDEX">
586   <TITLE>{{ .CollectionName }}</TITLE>
587   <STYLE type="text/css">
588     body {
589       margin: 1.5em;
590     }
591     pre {
592       background-color: #D9EDF7;
593       border-radius: .25em;
594       padding: .75em;
595       overflow: auto;
596     }
597     .footer p {
598       font-size: 82%;
599     }
600     ul {
601       padding: 0;
602     }
603     ul li {
604       font-family: monospace;
605       list-style: none;
606     }
607   </STYLE>
608 </HEAD>
609 <BODY>
610
611 <H1>{{ .CollectionName }}</H1>
612
613 <P>This collection of data files is being shared with you through
614 Arvados.  You can download individual files listed below.  To download
615 the entire directory tree with wget, try:</P>
616
617 <PRE>$ wget --mirror --no-parent --no-host --cut-dirs={{ .StripParts }} https://{{ .Request.Host }}{{ .Request.URL.Path }}</PRE>
618
619 <H2>File Listing</H2>
620
621 {{if .Files}}
622 <UL>
623 {{range .Files}}
624 {{if .IsDir }}
625   <LI>{{" " | printf "%15s  " | nbsp}}<A href="{{print "./" .Name}}/">{{.Name}}/</A></LI>
626 {{else}}
627   <LI>{{.Size | printf "%15d  " | nbsp}}<A href="{{print "./" .Name}}">{{.Name}}</A></LI>
628 {{end}}
629 {{end}}
630 </UL>
631 {{else}}
632 <P>(No files; this collection is empty.)</P>
633 {{end}}
634
635 <HR noshade>
636 <DIV class="footer">
637   <P>
638     About Arvados:
639     Arvados is a free and open source software bioinformatics platform.
640     To learn more, visit arvados.org.
641     Arvados is not responsible for the files listed on this page.
642   </P>
643 </DIV>
644
645 </BODY>
646 `
647
648 type fileListEnt struct {
649         Name  string
650         Size  int64
651         IsDir bool
652 }
653
654 func (h *handler) serveDirectory(w http.ResponseWriter, r *http.Request, collectionName string, fs http.FileSystem, base string, recurse bool) {
655         var files []fileListEnt
656         var walk func(string) error
657         if !strings.HasSuffix(base, "/") {
658                 base = base + "/"
659         }
660         walk = func(path string) error {
661                 dirname := base + path
662                 if dirname != "/" {
663                         dirname = strings.TrimSuffix(dirname, "/")
664                 }
665                 d, err := fs.Open(dirname)
666                 if err != nil {
667                         return err
668                 }
669                 ents, err := d.Readdir(-1)
670                 if err != nil {
671                         return err
672                 }
673                 for _, ent := range ents {
674                         if recurse && ent.IsDir() {
675                                 err = walk(path + ent.Name() + "/")
676                                 if err != nil {
677                                         return err
678                                 }
679                         } else {
680                                 files = append(files, fileListEnt{
681                                         Name:  path + ent.Name(),
682                                         Size:  ent.Size(),
683                                         IsDir: ent.IsDir(),
684                                 })
685                         }
686                 }
687                 return nil
688         }
689         if err := walk(""); err != nil {
690                 http.Error(w, err.Error(), http.StatusInternalServerError)
691                 return
692         }
693
694         funcs := template.FuncMap{
695                 "nbsp": func(s string) template.HTML {
696                         return template.HTML(strings.Replace(s, " ", "&nbsp;", -1))
697                 },
698         }
699         tmpl, err := template.New("dir").Funcs(funcs).Parse(dirListingTemplate)
700         if err != nil {
701                 http.Error(w, err.Error(), http.StatusInternalServerError)
702                 return
703         }
704         sort.Slice(files, func(i, j int) bool {
705                 return files[i].Name < files[j].Name
706         })
707         w.WriteHeader(http.StatusOK)
708         tmpl.Execute(w, map[string]interface{}{
709                 "CollectionName": collectionName,
710                 "Files":          files,
711                 "Request":        r,
712                 "StripParts":     strings.Count(strings.TrimRight(r.URL.Path, "/"), "/"),
713         })
714 }
715
716 func applyContentDispositionHdr(w http.ResponseWriter, r *http.Request, filename string, isAttachment bool) {
717         disposition := "inline"
718         if isAttachment {
719                 disposition = "attachment"
720         }
721         if strings.ContainsRune(r.RequestURI, '?') {
722                 // Help the UA realize that the filename is just
723                 // "filename.txt", not
724                 // "filename.txt?disposition=attachment".
725                 //
726                 // TODO(TC): Follow advice at RFC 6266 appendix D
727                 disposition += "; filename=" + strconv.QuoteToASCII(filename)
728         }
729         if disposition != "inline" {
730                 w.Header().Set("Content-Disposition", disposition)
731         }
732 }
733
734 func (h *handler) seeOtherWithCookie(w http.ResponseWriter, r *http.Request, location string, credentialsOK bool) {
735         if formToken := r.FormValue("api_token"); formToken != "" {
736                 if !credentialsOK {
737                         // It is not safe to copy the provided token
738                         // into a cookie unless the current vhost
739                         // (origin) serves only a single collection or
740                         // we are in TrustAllContent mode.
741                         w.WriteHeader(http.StatusBadRequest)
742                         return
743                 }
744
745                 // The HttpOnly flag is necessary to prevent
746                 // JavaScript code (included in, or loaded by, a page
747                 // in the collection being served) from employing the
748                 // user's token beyond reading other files in the same
749                 // domain, i.e., same collection.
750                 //
751                 // The 303 redirect is necessary in the case of a GET
752                 // request to avoid exposing the token in the Location
753                 // bar, and in the case of a POST request to avoid
754                 // raising warnings when the user refreshes the
755                 // resulting page.
756                 http.SetCookie(w, &http.Cookie{
757                         Name:     "arvados_api_token",
758                         Value:    auth.EncodeTokenCookie([]byte(formToken)),
759                         Path:     "/",
760                         HttpOnly: true,
761                 })
762         }
763
764         // Propagate query parameters (except api_token) from
765         // the original request.
766         redirQuery := r.URL.Query()
767         redirQuery.Del("api_token")
768
769         u := r.URL
770         if location != "" {
771                 newu, err := u.Parse(location)
772                 if err != nil {
773                         w.WriteHeader(http.StatusInternalServerError)
774                         return
775                 }
776                 u = newu
777         }
778         redir := (&url.URL{
779                 Scheme:   r.URL.Scheme,
780                 Host:     r.Host,
781                 Path:     u.Path,
782                 RawQuery: redirQuery.Encode(),
783         }).String()
784
785         w.Header().Add("Location", redir)
786         w.WriteHeader(http.StatusSeeOther)
787         io.WriteString(w, `<A href="`)
788         io.WriteString(w, html.EscapeString(redir))
789         io.WriteString(w, `">Continue</A>`)
790 }