14 "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
15 "git.curoverse.com/arvados.git/sdk/go/auth"
16 "git.curoverse.com/arvados.git/sdk/go/httpserver"
17 "git.curoverse.com/arvados.git/sdk/go/keepclient"
22 var clientPool = arvadosclient.MakeClientPool()
23 var anonymousTokens []string
25 // return a UUID or PDH if s begins with a UUID or URL-encoded PDH;
26 // otherwise return "".
27 func parseCollectionIdFromDNSName(s string) string {
29 if i := strings.IndexRune(s, '.'); i >= 0 {
32 // Names like {uuid}--dl.example.com serve the same purpose as
33 // {uuid}.dl.example.com but can reduce cost/effort of using
34 // [additional] wildcard certificates.
35 if i := strings.Index(s, "--"); i >= 0 {
38 if arvadosclient.UUIDMatch(s) {
41 if pdh := strings.Replace(s, "-", "+", 1); arvadosclient.PDHMatch(pdh) {
47 var urlPDHDecoder = strings.NewReplacer(" ", "+", "-", "+")
49 // return a UUID or PDH if s is a UUID or a PDH (even if it is a PDH
50 // with "+" replaced by " " or "-"); otherwise return "".
51 func parseCollectionIdFromURL(s string) string {
52 if arvadosclient.UUIDMatch(s) {
55 if pdh := urlPDHDecoder.Replace(s); arvadosclient.PDHMatch(pdh) {
61 func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
65 w := httpserver.WrapResponseWriter(wOrig)
68 statusCode = w.WroteStatus()
69 } else if w.WroteStatus() == 0 {
70 w.WriteHeader(statusCode)
71 } else if w.WroteStatus() != statusCode {
72 httpserver.Log(r.RemoteAddr, "WARNING",
73 fmt.Sprintf("Our status changed from %d to %d after we sent headers", w.WroteStatus(), statusCode))
76 statusText = http.StatusText(statusCode)
78 httpserver.Log(r.RemoteAddr, statusCode, statusText, w.WroteBodyBytes(), r.Method, r.Host, r.URL.Path, r.URL.RawQuery)
81 if r.Method != "GET" && r.Method != "POST" {
82 statusCode, statusText = http.StatusMethodNotAllowed, r.Method
86 arv := clientPool.Get()
88 statusCode, statusText = http.StatusInternalServerError, "Pool failed: "+clientPool.Err().Error()
91 defer clientPool.Put(arv)
93 pathParts := strings.Split(r.URL.Path[1:], "/")
96 var targetPath []string
98 var reqTokens []string
100 var credentialsOK bool
102 if targetId = parseCollectionIdFromDNSName(r.Host); targetId != "" {
103 // http://ID.dl.example/PATH...
105 targetPath = pathParts
106 } else if len(pathParts) >= 2 && strings.HasPrefix(pathParts[0], "c=") {
108 targetId = parseCollectionIdFromURL(pathParts[0][2:])
109 targetPath = pathParts[1:]
110 } else if len(pathParts) >= 3 && pathParts[0] == "collections" {
111 if len(pathParts) >= 5 && pathParts[1] == "download" {
112 // /collections/download/ID/TOKEN/PATH...
113 targetId = pathParts[2]
114 tokens = []string{pathParts[3]}
115 targetPath = pathParts[4:]
118 // /collections/ID/PATH...
119 targetId = pathParts[1]
120 tokens = anonymousTokens
121 targetPath = pathParts[2:]
124 statusCode = http.StatusNotFound
127 if t := r.FormValue("api_token"); t != "" {
128 // The client provided an explicit token in the query
129 // string, or a form in POST body. We must put the
130 // token in an HttpOnly cookie, and redirect to the
131 // same URL with the query param redacted and method =
135 // It is not safe to copy the provided token
136 // into a cookie unless the current vhost
137 // (origin) serves only a single collection.
138 statusCode = http.StatusBadRequest
142 // The HttpOnly flag is necessary to prevent
143 // JavaScript code (included in, or loaded by, a page
144 // in the collection being served) from employing the
145 // user's token beyond reading other files in the same
146 // domain, i.e., same collection.
148 // The 303 redirect is necessary in the case of a GET
149 // request to avoid exposing the token in the Location
150 // bar, and in the case of a POST request to avoid
151 // raising warnings when the user refreshes the
154 http.SetCookie(w, &http.Cookie{
156 Value: auth.EncodeTokenCookie([]byte(t)),
158 Expires: time.Now().AddDate(10,0,0),
161 redir := (&url.URL{Host: r.Host, Path: r.URL.Path}).String()
163 w.Header().Add("Location", redir)
164 statusCode, statusText = http.StatusSeeOther, redir
165 w.WriteHeader(statusCode)
166 io.WriteString(w, `<A href="`)
167 io.WriteString(w, html.EscapeString(redir))
168 io.WriteString(w, `">Continue</A>`)
172 if tokens == nil && strings.HasPrefix(targetPath[0], "t=") {
173 // http://ID.example/t=TOKEN/PATH...
174 // /c=ID/t=TOKEN/PATH...
176 // This form must only be used to pass scoped tokens
177 // that give permission for a single collection. See
178 // FormValue case above.
179 tokens = []string{targetPath[0][2:]}
181 targetPath = targetPath[1:]
186 reqTokens = auth.NewCredentialsFromHTTPRequest(r).Tokens
188 tokens = append(reqTokens, anonymousTokens...)
191 if len(targetPath) > 0 && targetPath[0] == "_" {
192 // If a collection has a directory called "t=foo" or
193 // "_", it can be served at //dl.example/_/t=foo/ or
194 // //dl.example/_/_/ respectively: //dl.example/t=foo/
195 // won't work because t=foo will be interpreted as a
197 targetPath = targetPath[1:]
200 tokenResult := make(map[string]int)
201 collection := make(map[string]interface{})
203 for _, arv.ApiToken = range tokens {
204 err := arv.Get("collections", targetId, nil, &collection)
210 if srvErr, ok := err.(arvadosclient.APIServerError); ok {
211 switch srvErr.HttpStatusCode {
213 // Token broken or insufficient to
214 // retrieve collection
215 tokenResult[arv.ApiToken] = srvErr.HttpStatusCode
219 // Something more serious is wrong
220 statusCode, statusText = http.StatusInternalServerError, err.Error()
224 if pathToken || !credentialsOK {
225 // Either the URL is a "secret sharing link"
226 // that didn't work out (and asking the client
227 // for additional credentials would just be
228 // confusing), or we don't even accept
229 // credentials at this path.
230 statusCode = http.StatusNotFound
233 for _, t := range reqTokens {
234 if tokenResult[t] == 404 {
235 // The client provided valid token(s), but the
236 // collection was not found.
237 statusCode = http.StatusNotFound
241 // The client's token was invalid (e.g., expired), or
242 // the client didn't even provide one. Propagate the
243 // 401 to encourage the client to use a [different]
246 // TODO(TC): This response would be confusing to
247 // someone trying (anonymously) to download public
248 // data that has been deleted. Allow a referrer to
249 // provide this context somehow?
250 w.Header().Add("WWW-Authenticate", "Basic realm=\"dl\"")
251 statusCode = http.StatusUnauthorized
255 filename := strings.Join(targetPath, "/")
256 kc, err := keepclient.MakeKeepClient(arv)
258 statusCode, statusText = http.StatusInternalServerError, err.Error()
261 rdr, err := kc.CollectionFileReader(collection, filename)
262 if os.IsNotExist(err) {
263 statusCode = http.StatusNotFound
265 } else if err != nil {
266 statusCode, statusText = http.StatusBadGateway, err.Error()
271 // One or both of these can be -1 if not found:
272 basenamePos := strings.LastIndex(filename, "/")
273 extPos := strings.LastIndex(filename, ".")
274 if extPos > basenamePos {
275 // Now extPos is safely >= 0.
276 if t := mime.TypeByExtension(filename[extPos:]); t != "" {
277 w.Header().Set("Content-Type", t)
280 w.Header().Set("Content-Length", fmt.Sprintf("%d", rdr.Len()))
282 w.WriteHeader(http.StatusOK)
283 _, err = io.Copy(w, rdr)
285 statusCode, statusText = http.StatusBadGateway, err.Error()