| 1 | package blob |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "io" |
| 8 | "net/http" |
| 9 | "net/url" |
| 10 | ) |
| 11 | |
| 12 | // maxPathUploadSources bounds the remote sources one BUD-13 upload may name. |
| 13 | const maxPathUploadSources = 8 |
| 14 | |
| 15 | // pathUpload implements the draft BUD-13 path-addressed upload endpoint. |
| 16 | // The route itself is dispatched by serveHTTP after validating the hash path. |
| 17 | func (s *Service) pathUpload(w http.ResponseWriter, r *http.Request, expectedHash string) { |
| 18 | // Authenticate before inspecting the body. This is important for signed |
| 19 | // uploads: an invalid request must not consume a potentially large stream. |
| 20 | pubkey, err := s.authorize(r, ActionUpload) |
| 21 | if err != nil { |
| 22 | s.fail(w, http.StatusUnauthorized, err) |
| 23 | return |
| 24 | } |
| 25 | |
| 26 | urls := r.URL.Query()["url" ] |
| 27 | if len(urls) == 0 { |
| 28 | entry, created, putErr := s.putValidatedWithMetadata(r.Context(), r.Body, |
| 29 | contentType(r.Header.Get("content-type" )), pubkey, expectedHash, |
| 30 | r.URL.Query().Get("filename" ), r.URL.Query().Get("path" ), r.URL.Query().Get("access" ), r.URL.Query().Get("purpose" ), |
| 31 | func(actual string) error { |
| 32 | if s.config.ValidateUpload != nil { |
| 33 | return s.config.ValidateUpload(r, actual) |
| 34 | } |
| 35 | return nil |
| 36 | }, nil) |
| 37 | if putErr != nil { |
| 38 | s.fail(w, statusFor(putErr), putErr) |
| 39 | return |
| 40 | } |
| 41 | s.writeJSON(w, chooseStatus(created, http.StatusCreated), s.descriptor(r, entry)) |
| 42 | return |
| 43 | } |
| 44 | |
| 45 | // BUD-13 remote uploads must have an empty request body. Check this after |
| 46 | // authorization, including chunked requests whose ContentLength is unknown. |
| 47 | if r.Body != nil { |
| 48 | var probe [1]byte |
| 49 | n, readErr := io.ReadFull(r.Body, probe[:]) |
| 50 | if n != 0 || (readErr != nil && !errors.Is(readErr, io.EOF)) { |
| 51 | s.fail(w, http.StatusBadRequest, errors.New("invalid: remote upload body must be empty" )) |
| 52 | return |
| 53 | } |
| 54 | } |
| 55 | if len(urls) > maxPathUploadSources { |
| 56 | s.fail(w, http.StatusBadRequest, fmt.Errorf("invalid: at most %d remote sources per upload" , maxPathUploadSources)) |
| 57 | return |
| 58 | } |
| 59 | for _, raw := range urls { |
| 60 | if err := s.validatePathUploadURL(r.Context(), raw); err != nil { |
| 61 | s.fail(w, http.StatusBadRequest, err) |
| 62 | return |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | client := s.config.HTTPClient |
| 67 | if client == nil { |
| 68 | client = s.safeClient() |
| 69 | } |
| 70 | var mismatch bool |
| 71 | for _, raw := range urls { |
| 72 | fetchRequest, requestErr := http.NewRequestWithContext(r.Context(), http.MethodGet, raw, nil) |
| 73 | if requestErr != nil { |
| 74 | continue |
| 75 | } |
| 76 | response, fetchErr := client.Do(fetchRequest) |
| 77 | if fetchErr != nil { |
| 78 | continue |
| 79 | } |
| 80 | if response.StatusCode < 200 || response.StatusCode >= 300 { |
| 81 | _ = response.Body.Close() |
| 82 | continue |
| 83 | } |
| 84 | typ := contentType(response.Header.Get("content-type" )) |
| 85 | if typ == "application/octet-stream" { |
| 86 | typ = typeForExtension(raw) |
| 87 | } |
| 88 | entry, created, putErr := s.putValidatedWithMetadata(r.Context(), response.Body, typ, |
| 89 | pubkey, expectedHash, "" , "" , r.URL.Query().Get("access" ), r.URL.Query().Get("purpose" ), func(actual string) error { |
| 90 | if s.config.ValidateUpload != nil { |
| 91 | return s.config.ValidateUpload(r, actual) |
| 92 | } |
| 93 | return nil |
| 94 | }, nil) |
| 95 | _ = response.Body.Close() |
| 96 | if putErr == nil { |
| 97 | s.writeJSON(w, chooseStatus(created, http.StatusCreated), s.descriptor(r, entry)) |
| 98 | return |
| 99 | } |
| 100 | if errors.Is(putErr, ErrHashMismatch) { |
| 101 | mismatch = true |
| 102 | continue |
| 103 | } |
| 104 | s.fail(w, statusFor(putErr), putErr) |
| 105 | return |
| 106 | } |
| 107 | if mismatch { |
| 108 | s.fail(w, http.StatusConflict, ErrHashMismatch) |
| 109 | return |
| 110 | } |
| 111 | s.fail(w, http.StatusBadGateway, errors.New("error: no usable remote source" )) |
| 112 | } |
| 113 | |
| 114 | func (s *Service) validatePathUploadURL(ctx context.Context, raw string) error { |
| 115 | u, err := url.Parse(raw) |
| 116 | if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https" ) || u.Hostname() == "" || u.User != nil || u.Fragment != "" { |
| 117 | return errors.New("invalid: url must be an absolute http or https url" ) |
| 118 | } |
| 119 | ips, err := s.resolve(ctx, u.Hostname()) |
| 120 | if err != nil || len(ips) == 0 { |
| 121 | return errors.New("invalid: origin hostname did not resolve" ) |
| 122 | } |
| 123 | for _, ip := range ips { |
| 124 | if !publicIP(ip) { |
| 125 | return errors.New("invalid: origin resolves to a private address" ) |
| 126 | } |
| 127 | } |
| 128 | return nil |
| 129 | } |