mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
Part 1/5 of the tea 1.0 rewrite. **Stack** (split from #1121; each PR targets `main` and builds, tests and lints on its own — the shipped CLI only changes in part 5): 1. #1122 Toolchain and foundation packages ← this PR 2. #1123 Credentials and Git 3. #1124 Terminal 4. #1125 Services 5. #1126 CLI wiring, docs, removal of the old tree ## Changes - **build:** require Go 1.27; bump gofumpt and golangci-lint to releases that can read Go 1.27 export data (own commit). - `internal/clierr`: typed errors and executable exit codes. - `internal/version`: build version and tags. - `internal/filelock`: moved unchanged from `modules/filelock`. - `internal/aliases`: user-defined command aliases. - `internal/transport`: the HTTP client the Gitea SDK runs on (part 4): token, Sudo and SSH-signature auth, retries for safe reads, connect/TLS/response-header timeouts, typed API errors that keep Gitea's message, and a redacted request log for `--debug`; plus raw requests for `tea api`, signed downloads that follow object-storage redirects, and log streaming. - `internal/settings`: split `config.yml` / `hosts.yml` with locked transactions, plus remote URL parsing. Nothing imports these packages yet. Note: `flake.nix` now asks for `go_1_27`; the pinned nixpkgs in `flake.lock` predates Go 1.27 and still needs a `nix flake update nixpkgs`. Reviewed-on: https://gitea.com/gitea/tea/pulls/1122 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
228 lines
7.6 KiB
Go
228 lines
7.6 KiB
Go
// 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
|
|
}
|
|
|
|
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:
|
|
text = "resource or API endpoint unavailable on the selected Gitea instance"
|
|
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
|
|
}
|
|
|
|
const (
|
|
maxErrorBody = 4 << 10
|
|
maxErrorMessage = 300
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 {
|
|
return fmt.Errorf("%s: %w", action, urlErr.Err)
|
|
}
|
|
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
|
|
}
|