// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT // Package transport provides scoped, cancellable Gitea API requests. package transport import ( "context" "crypto/tls" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "runtime" "strings" "time" "unicode" "gitea.dev/tea/internal/version" ) // RequestSigner attaches HTTP signature headers for SSH/HTTPSign authentication. type RequestSigner interface { SignRequest(r *http.Request, contents []byte) error } // APIError reports request failure without including credentials or raw response bodies. // Message holds only the sanitized "message" field of a JSON error body. type APIError struct { Status int Method, Endpoint, Message string // Instance is the installation URL the failed request was sent to, when it was recorded // from a request. Instance string // apiBase and lookup repeat reads on the failed request's instance with its credential. apiBase string lookup http.RoundTripper } func (e *APIError) Error() string { var text string switch e.Status { case 401: text = "authentication rejected; run tea auth login" case 403: text = "permission denied by the Gitea instance" case 404: // Gitea's 404 message is usually just "not found"; the request names what was missing. text = "not found (" + e.request() + ")" if strings.EqualFold(e.Message, "not found") { return text } case 429: text = "Gitea rate limit exceeded; try again later" default: text = fmt.Sprintf("Gitea API returned HTTP %d for %s %s", e.Status, e.Method, e.Endpoint) } if e.Message != "" { text += ": " + e.Message } return text } // request describes the failed request, such as "HTTP 404 GET /api/v1/repos/alice/x". func (e *APIError) request() string { return strings.TrimSpace(fmt.Sprintf("HTTP %d %s %s", e.Status, e.Method, e.Endpoint)) } // bind records the instance of the request to u and the credentialed transport rt, so the // failure can later be explained with Lookup. func (e *APIError) bind(u *url.URL, rt http.RoundTripper) { prefix, _, ok := strings.Cut(u.Path, "/api/v1/") if !ok { return } instance := url.URL{Scheme: u.Scheme, Host: u.Host, Path: prefix} e.Instance = instance.String() instance.Path += "/api/v1/" e.apiBase, e.lookup = instance.String(), rt } // Lookup reads endpoint, a path below the API base such as "repos/alice/project", with the // instance and credential of the request that failed, and decodes a successful JSON response // into out. It returns the response status, or 0 when the error was not recorded from a // request or the lookup itself failed. func (e *APIError) Lookup(ctx context.Context, endpoint string, out any) int { if e.lookup == nil { return 0 } req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.apiBase+strings.TrimLeft(endpoint, "/"), nil) if err != nil { return 0 } req.Header.Set("Accept", "application/json") resp, err := e.lookup.RoundTrip(req) if err != nil { return 0 } defer resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 && out != nil && json.NewDecoder(io.LimitReader(resp.Body, maxLookupBody)).Decode(out) != nil { return 0 } return resp.StatusCode } const ( maxErrorBody = 4 << 10 maxErrorMessage = 300 maxLookupBody = 1 << 20 ) // newAPIError keeps only a bounded, printable JSON "message" field of a failed response body. // Authentication failures and messages echoing the token keep no server text. func newAPIError(status int, method, endpoint, token string, data []byte) *APIError { failure := &APIError{Status: status, Method: method, Endpoint: endpoint} if status == http.StatusUnauthorized { return failure } var body struct { Message string `json:"message"` } if json.Unmarshal(data, &body) != nil || (token != "" && strings.Contains(body.Message, token)) { return failure } message := strings.Join(strings.FieldsFunc(body.Message, func(r rune) bool { return unicode.IsControl(r) || unicode.IsSpace(r) || unicode.Is(unicode.Cf, r) }), " ") if runes := []rune(message); len(runes) > maxErrorMessage { message = string(runes[:maxErrorMessage]) + "..." } failure.Message = message return failure } // Client binds HTTP requests to one instance and credential. type Client struct { BaseURL string Token string // Sudo asks the instance to perform requests as this user (administrators only). Sudo string Signer RequestSigner HTTP *http.Client Retries int } // UserAgent identifies tea to instances, proxies, and server logs. var UserAgent = "tea/" + version.Version + " (" + runtime.GOOS + "/" + runtime.GOARCH + ")" type userAgentTransport struct{ base http.RoundTripper } func (t userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { if req.Header.Get("User-Agent") == "" { req = req.Clone(req.Context()) req.Header.Set("User-Agent", UserAgent) } return t.base.RoundTrip(req) } // New constructs an instance-scoped client with no redirects. Only connection setup and the wait // for response headers are bounded, so slow but progressing transfers are not cut off. func New(instance, token string, insecure bool) *Client { tr := http.DefaultTransport.(*http.Transport).Clone() tr.DialContext = (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext tr.TLSHandshakeTimeout = 10 * time.Second tr.ResponseHeaderTimeout = 120 * time.Second tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: insecure} // user-controlled, instance-scoped return &Client{BaseURL: instance, Token: token, Retries: 2, HTTP: &http.Client{Transport: userAgentTransport{tr}, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}} } // WithTimeout returns an independent HTTP policy while sharing the connection transport. func (c *Client) WithTimeout(timeout time.Duration) *Client { clone := *c httpClient := *c.HTTP httpClient.Timeout = timeout clone.HTTP = &httpClient return &clone } // scoped parses raw for this instance, rejecting userinfo, fragments, dot // segments, and backslashes. kind names the reference in error messages. func (c *Client) scoped(raw, kind string) (*url.URL, *url.URL, error) { base, err := url.Parse(c.BaseURL) if err != nil { return nil, nil, errors.New("invalid instance URL") } u, err := url.Parse(raw) if err != nil || u.User != nil || u.Fragment != "" { return nil, nil, errors.New("invalid " + kind) } for segment := range strings.SplitSeq(u.Path, "/") { if segment == ".." || segment == "." { return nil, nil, errors.New(kind + " cannot contain path traversal") } } if strings.Contains(u.Path, "\\") { return nil, nil, errors.New(kind + " cannot contain backslashes") } return base, u, nil } // within reports whether u uses the instance scheme and host and lies below its base path plus under. func within(base, u *url.URL, under string) bool { return u.Scheme == base.Scheme && strings.EqualFold(u.Host, base.Host) && strings.HasPrefix(u.Path, strings.TrimRight(base.Path, "/")+under) } // URL accepts only endpoints within the selected installation API. func (c *Client) URL(endpoint string) (string, error) { base, u, err := c.scoped(endpoint, "API endpoint") if err != nil { return "", err } if u.IsAbs() || u.Host != "" { if !within(base, u, "/api/") { return "", errors.New("API URL must belong to the selected instance and its API base path") } return u.String(), nil } endpointPath := u.Path escapedEndpointPath := u.EscapedPath() if !strings.HasPrefix(endpointPath, "/api/") { endpointPath = "/api/v1/" + strings.TrimLeft(endpointPath, "/") escapedEndpointPath = "/api/v1/" + strings.TrimLeft(escapedEndpointPath, "/") } base.RawPath = strings.TrimRight(base.EscapedPath(), "/") + escapedEndpointPath base.Path = strings.TrimRight(base.Path, "/") + endpointPath base.RawQuery = u.RawQuery return base.String(), nil } // RequestFailure reports a request that received no HTTP response, such as a refused // connection or an untrusted certificate. type RequestFailure struct { // Action names what failed, such as "API request failed". Action string // Server is the scheme and host the request was sent to, without path or query. Server string // Err is the network or TLS cause. Err error } func (e *RequestFailure) Error() string { return e.Action + ": " + e.Err.Error() } func (e *RequestFailure) Unwrap() error { return e.Err } // RequestError keeps the network or TLS cause of a failed request. The cause of a *url.Error // omits the request URL, whose query may carry secrets. func RequestError(ctx context.Context, action string, err error) error { if ctx.Err() != nil { return ctx.Err() } if urlErr, ok := errors.AsType[*url.Error](err); ok { failure := &RequestFailure{Action: action, Err: urlErr.Err} if u, parseErr := url.Parse(urlErr.URL); parseErr == nil { failure.Server = (&url.URL{Scheme: u.Scheme, Host: u.Host}).String() } return failure } return errors.New(action + "; check connectivity and TLS settings") } // Do executes a cancellable request, retrying only safe body-free reads. // A response outside 2xx is closed and returned as an *APIError. func (c *Client) Do(ctx context.Context, method, endpoint string, body io.Reader, headers http.Header) (*http.Response, error) { ctx, failure := CaptureErrors(ctx) resp, err := c.Send(ctx, method, endpoint, body, headers) if err != nil || (resp.StatusCode >= 200 && resp.StatusCode < 300) { return resp, err } _ = resp.Body.Close() return nil, failure() } // Send is Do without converting responses outside 2xx into errors. func (c *Client) Send(ctx context.Context, method, endpoint string, body io.Reader, headers http.Header) (*http.Response, error) { requestURL, err := c.URL(endpoint) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, method, requestURL, body) if err != nil { return nil, errors.New("cannot construct API request") } req.Header = headers.Clone() if req.Header == nil { req.Header = http.Header{} } if body != nil && req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", "application/json") } if req.Header.Get("Accept") == "" { req.Header.Set("Accept", "application/json") } resp, err := c.API().Do(req) if err != nil { return nil, RequestError(ctx, "API request failed", err) } return resp, nil }