Replaces the shared GITEA_MCP_DEFAULT_TOKEN for all callers. When a request's bearer validates directly against Gitea's own /api/v1/user, that token is used for every upstream call this request makes instead of the service PAT, and the caller identity comes from Gitea's own login rather than the proxy header. Any other bearer (static token, JWT, none) falls through unchanged to the existing chassis auth. Prep: Authentik now SSOs into Gitea (infra a32801c), so each real user can mint their own PAT from their own linked account. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
193 lines
5.5 KiB
Go
193 lines
5.5 KiB
Go
package gitea
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hashicorp/golang-lru/v2/expirable"
|
|
)
|
|
|
|
type Client struct {
|
|
baseURL string
|
|
token string
|
|
hc *http.Client
|
|
branchCache *expirable.LRU[string, string]
|
|
}
|
|
|
|
type ctxTokenKey struct{}
|
|
|
|
// WithToken overrides the token used for upstream Gitea calls made with the
|
|
// returned context, taking precedence over the Client's configured default
|
|
// token. Used for per-caller PAT pass-through (gitea-mcp#59).
|
|
func WithToken(ctx context.Context, token string) context.Context {
|
|
return context.WithValue(ctx, ctxTokenKey{}, token)
|
|
}
|
|
|
|
// TokenFromContext returns the token set by WithToken, if any.
|
|
func TokenFromContext(ctx context.Context) (string, bool) {
|
|
v, ok := ctx.Value(ctxTokenKey{}).(string)
|
|
return v, ok
|
|
}
|
|
|
|
func NewClient(baseURL, token string) *Client {
|
|
return &Client{
|
|
baseURL: baseURL,
|
|
token: token,
|
|
hc: &http.Client{Timeout: 30 * time.Second},
|
|
branchCache: expirable.NewLRU[string, string](64, nil, 60*time.Second),
|
|
}
|
|
}
|
|
|
|
// ValidateToken asks Gitea who a given token belongs to (GET /api/v1/user
|
|
// using that token, not the client's configured default token) and returns
|
|
// its login name. Used for per-caller PAT pass-through (gitea-mcp#59).
|
|
func (c *Client) ValidateToken(ctx context.Context, token string) (string, bool) {
|
|
body, status, err := c.doOnce(WithToken(ctx, token), http.MethodGet, "/api/v1/user", nil)
|
|
if err != nil || status != http.StatusOK {
|
|
return "", false
|
|
}
|
|
var user struct {
|
|
Login string `json:"login"`
|
|
}
|
|
if err := json.Unmarshal(body, &user); err != nil || user.Login == "" {
|
|
return "", false
|
|
}
|
|
return user.Login, true
|
|
}
|
|
|
|
// DefaultBranch returns the default branch for a repo. Cached for 60s.
|
|
func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string, error) {
|
|
key := owner + "/" + name
|
|
if v, ok := c.branchCache.Get(key); ok {
|
|
return v, nil
|
|
}
|
|
repo, err := c.GetRepo(ctx, owner, name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
c.branchCache.Add(key, repo.DefaultBranch)
|
|
return repo.DefaultBranch, nil
|
|
}
|
|
|
|
// hasEmptySegment reports whether the path portion (before any query string)
|
|
// contains an empty segment ("//"), which means an owner or repo path
|
|
// parameter was empty. Forwarding it upstream yields gitea's opaque
|
|
// /api/swagger 404 (#36), so callers reject it locally instead.
|
|
func hasEmptySegment(path string) bool {
|
|
if i := strings.IndexByte(path, '?'); i >= 0 {
|
|
path = path[:i]
|
|
}
|
|
return strings.Contains(path, "//")
|
|
}
|
|
|
|
func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
|
|
if hasEmptySegment(path) {
|
|
return nil, 0, fmt.Errorf("%w: upstream path %q has an empty owner or repo segment", ErrValidation, path)
|
|
}
|
|
var reader io.Reader
|
|
if body != nil {
|
|
reader = bytes.NewReader(body)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
token := c.token
|
|
if override, ok := TokenFromContext(ctx); ok {
|
|
token = override
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "token "+token)
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := c.hc.Do(req)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
b, err := io.ReadAll(resp.Body)
|
|
return b, resp.StatusCode, err
|
|
}
|
|
|
|
func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
|
|
b, status, err := c.doOnce(ctx, method, path, body)
|
|
if err == nil && method == http.MethodGet && status >= 500 && status < 600 {
|
|
time.Sleep(250 * time.Millisecond)
|
|
return c.doOnce(ctx, method, path, body)
|
|
}
|
|
return b, status, err
|
|
}
|
|
|
|
func (c *Client) GetJSON(ctx context.Context, path string) ([]byte, int, error) {
|
|
return c.do(ctx, http.MethodGet, path, nil)
|
|
}
|
|
|
|
func (c *Client) PostJSON(ctx context.Context, path string, body []byte) ([]byte, int, error) {
|
|
return c.do(ctx, http.MethodPost, path, body)
|
|
}
|
|
|
|
func (c *Client) PatchJSON(ctx context.Context, path string, body []byte) ([]byte, int, error) {
|
|
return c.do(ctx, http.MethodPatch, path, body)
|
|
}
|
|
|
|
func (c *Client) PutJSON(ctx context.Context, path string, body []byte) ([]byte, int, error) {
|
|
return c.do(ctx, http.MethodPut, path, body)
|
|
}
|
|
|
|
func (c *Client) DeleteJSON(ctx context.Context, path string) ([]byte, int, error) {
|
|
return c.do(ctx, http.MethodDelete, path, nil)
|
|
}
|
|
|
|
func (c *Client) DeleteJSONBody(ctx context.Context, path string, body []byte) ([]byte, int, error) {
|
|
return c.do(ctx, http.MethodDelete, path, body)
|
|
}
|
|
|
|
type rawResponse struct {
|
|
Body []byte
|
|
Status int
|
|
Headers http.Header
|
|
}
|
|
|
|
func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*rawResponse, error) {
|
|
if hasEmptySegment(path) {
|
|
return nil, fmt.Errorf("%w: upstream path %q has an empty owner or repo segment", ErrValidation, path)
|
|
}
|
|
var reader io.Reader
|
|
if body != nil {
|
|
reader = bytes.NewReader(body)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
token := c.token
|
|
if override, ok := TokenFromContext(ctx); ok {
|
|
token = override
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "token "+token)
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := c.hc.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
b, err := io.ReadAll(resp.Body)
|
|
return &rawResponse{Body: b, Status: resp.StatusCode, Headers: resp.Header}, err
|
|
}
|