Files
gitea-tea/internal/transport/debug.go
T

110 lines
3.2 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package transport
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"unicode"
)
type debugTransport struct {
base http.RoundTripper
out io.Writer
}
func (t debugTransport) RoundTrip(req *http.Request) (*http.Response, error) {
start := time.Now()
resp, err := t.base.RoundTrip(req)
var outcome, message string
if err != nil {
outcome = "error: " + err.Error()
} else {
outcome = plainText(resp.Status)
message = failureMessage(req, resp)
}
line := fmt.Sprintf("[debug] %s %s -> %s (%s)", req.Method, redactURL(req.URL), outcome, time.Since(start).Round(time.Millisecond))
if message != "" {
line += ": " + message
}
_, _ = fmt.Fprintln(t.out, line)
return resp, err
}
// plainText drops control and format characters, which a server can put in the reason phrase
// to drive the terminal the log is printed to.
func plainText(s string) string {
return strings.Join(strings.FieldsFunc(s, func(r rune) bool { return unicode.IsControl(r) || unicode.IsSpace(r) || unicode.Is(unicode.Cf, r) }), " ")
}
// failureMessage returns the sanitized server message of a failed response and keeps its body
// readable for the caller.
func failureMessage(req *http.Request, resp *http.Response) string {
if resp.StatusCode < 400 {
return ""
}
data, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))
resp.Body = struct {
io.Reader
io.Closer
}{io.MultiReader(bytes.NewReader(data), resp.Body), resp.Body}
token := strings.TrimPrefix(req.Header.Get("Authorization"), "token ")
return newAPIError(resp.StatusCode, req.Method, req.URL.Path, token, data).Message
}
// redactURL drops userinfo and the values of query parameters that can carry credentials or
// signatures, keeping filters such as limit, page and state readable.
func redactURL(u *url.URL) string {
clean := *u
clean.User = nil
if clean.RawQuery != "" {
query := clean.Query()
for key := range query {
if sensitiveQuery(clean.Path, key) {
query[key] = []string{"REDACTED"}
}
}
clean.RawQuery = query.Encode()
}
return clean.String()
}
// sensitiveQuery reports query parameters whose values can authenticate a request: tokens,
// passwords, secrets, signatures and credentials of signed storage URLs, and OAuth codes and state.
func sensitiveQuery(path, key string) bool {
key = strings.ToLower(key)
switch {
case key == "code" || key == "key" || strings.HasPrefix(key, "sig"):
return true
case key == "state":
return strings.Contains(path, "/login/oauth/")
}
for _, word := range []string{"token", "password", "secret", "signature", "credential"} {
if strings.Contains(key, word) {
return true
}
}
return false
}
// Debug returns a copy of c that logs the method, redacted URL, status, and duration of each
// request to w, plus the sanitized server message of a failed response. Headers and bodies are
// never logged.
func (c *Client) Debug(w io.Writer) *Client {
clone := *c
httpClient := *c.HTTP
base := httpClient.Transport
if base == nil {
base = http.DefaultTransport
}
httpClient.Transport = debugTransport{base: base, out: w}
clone.HTTP = &httpClient
return &clone
}