mirror of
https://gitea.com/gitea/tea.git
synced 2026-02-22 06:13:32 +01:00
Add api subcommand for arbitrary api calls not covered by existing subcommands (#879)
Reviewed-on: https://gitea.com/gitea/tea/pulls/879 Co-authored-by: techknowlogick <techknowlogick@gitea.com> Co-committed-by: techknowlogick <techknowlogick@gitea.com>
This commit is contained in:
committed by
techknowlogick
parent
6414a5e00e
commit
82d8a14c73
105
modules/api/client.go
Normal file
105
modules/api/client.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/tea/modules/config"
|
||||
)
|
||||
|
||||
// Client provides direct HTTP access to Gitea API
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new API client from a Login config
|
||||
func NewClient(login *config.Login) *Client {
|
||||
// Refresh OAuth token if expired or near expiry
|
||||
if err := login.RefreshOAuthTokenIfNeeded(); err != nil {
|
||||
log.Printf("Warning: failed to refresh OAuth token: %v", err)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: login.Insecure},
|
||||
},
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: strings.TrimSuffix(login.URL, "/"),
|
||||
token: login.Token,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
// Do executes an HTTP request with authentication headers
|
||||
func (c *Client) Do(method, endpoint string, body io.Reader, headers map[string]string) (*http.Response, error) {
|
||||
// Build the full URL
|
||||
reqURL, err := c.buildURL(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, reqURL, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set authentication header
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
}
|
||||
|
||||
// Set default content type for requests with body
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Apply custom headers (can override defaults)
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
return c.httpClient.Do(req)
|
||||
}
|
||||
|
||||
// buildURL constructs the full URL from an endpoint
|
||||
func (c *Client) buildURL(endpoint string) (string, error) {
|
||||
// If endpoint is already a full URL, validate it matches the login's host
|
||||
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
|
||||
endpointURL, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
baseURL, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid base URL: %w", err)
|
||||
}
|
||||
if endpointURL.Host != baseURL.Host {
|
||||
return "", fmt.Errorf("URL host %q does not match login host %q (token would be sent to wrong server)", endpointURL.Host, baseURL.Host)
|
||||
}
|
||||
return endpoint, nil
|
||||
}
|
||||
|
||||
// Ensure endpoint starts with /
|
||||
if !strings.HasPrefix(endpoint, "/") {
|
||||
endpoint = "/" + endpoint
|
||||
}
|
||||
|
||||
// Auto-prefix /api/v1/ if not present
|
||||
if !strings.HasPrefix(endpoint, "/api/") {
|
||||
endpoint = "/api/v1" + endpoint
|
||||
}
|
||||
|
||||
return c.baseURL + endpoint, nil
|
||||
}
|
||||
@@ -27,9 +27,6 @@ import (
|
||||
|
||||
// Constants for OAuth2 PKCE flow
|
||||
const (
|
||||
// default client ID included in most Gitea instances
|
||||
defaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4"
|
||||
|
||||
// default scopes to request
|
||||
defaultScopes = "admin,user,issue,misc,notification,organization,package,repository"
|
||||
|
||||
@@ -65,7 +62,7 @@ func OAuthLoginWithOptions(name, giteaURL string, insecure bool) error {
|
||||
Name: name,
|
||||
URL: giteaURL,
|
||||
Insecure: insecure,
|
||||
ClientID: defaultClientID,
|
||||
ClientID: config.DefaultClientID,
|
||||
RedirectURL: fmt.Sprintf("http://%s:%d", redirectHost, redirectPort),
|
||||
Port: redirectPort,
|
||||
}
|
||||
@@ -82,7 +79,7 @@ func OAuthLoginWithFullOptions(opts OAuthOptions) error {
|
||||
|
||||
// Set defaults if needed
|
||||
if opts.ClientID == "" {
|
||||
opts.ClientID = defaultClientID
|
||||
opts.ClientID = config.DefaultClientID
|
||||
}
|
||||
|
||||
// If the redirect URL is specified, parse it to extract port if needed
|
||||
@@ -414,55 +411,9 @@ func createLoginFromToken(name, serverURL string, token *oauth2.Token, insecure
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshAccessToken manually renews an expired access token using the refresh token
|
||||
// RefreshAccessToken manually renews an access token using the refresh token.
|
||||
// This is used by the "tea login oauth-refresh" command for explicit token refresh.
|
||||
// For automatic threshold-based refresh, use login.Client() which handles it internally.
|
||||
func RefreshAccessToken(login *config.Login) error {
|
||||
if login.RefreshToken == "" {
|
||||
return fmt.Errorf("no refresh token available")
|
||||
}
|
||||
|
||||
// Check if token actually needs refreshing
|
||||
if login.TokenExpiry > 0 && time.Now().Unix() < login.TokenExpiry {
|
||||
// Token is still valid, no need to refresh
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create an expired Token object
|
||||
expiredToken := &oauth2.Token{
|
||||
AccessToken: login.Token,
|
||||
RefreshToken: login.RefreshToken,
|
||||
// Set expiry in the past to force refresh
|
||||
Expiry: time.Unix(login.TokenExpiry, 0),
|
||||
}
|
||||
|
||||
// Set up the OAuth2 config
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, createHTTPClient(login.Insecure))
|
||||
|
||||
// Configure the OAuth2 endpoints
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: defaultClientID,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", login.URL),
|
||||
},
|
||||
}
|
||||
|
||||
// Refresh the token
|
||||
newToken, err := oauth2Config.TokenSource(ctx, expiredToken).Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to refresh token: %s", err)
|
||||
}
|
||||
|
||||
// Update login with new token information
|
||||
login.Token = newToken.AccessToken
|
||||
|
||||
if newToken.RefreshToken != "" {
|
||||
login.RefreshToken = newToken.RefreshToken
|
||||
}
|
||||
|
||||
if !newToken.Expiry.IsZero() {
|
||||
login.TokenExpiry = newToken.Expiry.Unix()
|
||||
}
|
||||
|
||||
// Save updated login to config
|
||||
return config.UpdateLogin(login)
|
||||
return login.RefreshOAuthToken()
|
||||
}
|
||||
|
||||
@@ -24,6 +24,13 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// TokenRefreshThreshold is how far before expiry we should refresh OAuth tokens.
|
||||
// This is used by config.Login.Client() for automatic token refresh.
|
||||
const TokenRefreshThreshold = 5 * time.Minute
|
||||
|
||||
// DefaultClientID is the default OAuth2 client ID included in most Gitea instances
|
||||
const DefaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4"
|
||||
|
||||
// Login represents a login to a gitea server, you even could add multiple logins for one gitea server
|
||||
type Login struct {
|
||||
Name string `yaml:"name"`
|
||||
@@ -129,21 +136,31 @@ func GetLoginByToken(token string) *Login {
|
||||
|
||||
// GetLoginByHost finds a login by it's server URL
|
||||
func GetLoginByHost(host string) *Login {
|
||||
logins := GetLoginsByHost(host)
|
||||
if len(logins) > 0 {
|
||||
return logins[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLoginsByHost returns all logins matching a host
|
||||
func GetLoginsByHost(host string) []*Login {
|
||||
err := loadConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for _, l := range config.Logins {
|
||||
loginURL, err := url.Parse(l.URL)
|
||||
var matches []*Login
|
||||
for i := range config.Logins {
|
||||
loginURL, err := url.Parse(config.Logins[i].URL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if loginURL.Host == host {
|
||||
return &l
|
||||
matches = append(matches, &config.Logins[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return matches
|
||||
}
|
||||
|
||||
// DeleteLogin delete a login by name from config
|
||||
@@ -208,63 +225,79 @@ func UpdateLogin(login *Login) error {
|
||||
return saveConfig()
|
||||
}
|
||||
|
||||
// RefreshOAuthTokenIfNeeded refreshes the OAuth token if it's expired or near expiry.
|
||||
// Returns nil without doing anything if no refresh is needed.
|
||||
func (l *Login) RefreshOAuthTokenIfNeeded() error {
|
||||
if l.RefreshToken == "" || l.TokenExpiry == 0 {
|
||||
return nil
|
||||
}
|
||||
expiryTime := time.Unix(l.TokenExpiry, 0)
|
||||
if time.Now().Add(TokenRefreshThreshold).After(expiryTime) {
|
||||
return l.RefreshOAuthToken()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshOAuthToken refreshes the OAuth access token using the refresh token.
|
||||
// It updates the login with new token information and saves it to config.
|
||||
func (l *Login) RefreshOAuthToken() error {
|
||||
if l.RefreshToken == "" {
|
||||
return fmt.Errorf("no refresh token available")
|
||||
}
|
||||
|
||||
// Create a Token object with current values
|
||||
currentToken := &oauth2.Token{
|
||||
AccessToken: l.Token,
|
||||
RefreshToken: l.RefreshToken,
|
||||
Expiry: time.Unix(l.TokenExpiry, 0),
|
||||
}
|
||||
|
||||
// Set up the OAuth2 config
|
||||
ctx := context.Background()
|
||||
|
||||
// Create HTTP client, respecting the login's TLS settings
|
||||
httpClient := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: l.Insecure},
|
||||
},
|
||||
}
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
|
||||
// Configure the OAuth2 endpoints
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: DefaultClientID,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL),
|
||||
},
|
||||
}
|
||||
|
||||
// Refresh the token
|
||||
newToken, err := oauth2Config.TokenSource(ctx, currentToken).Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
|
||||
// Update login with new token information
|
||||
l.Token = newToken.AccessToken
|
||||
|
||||
if newToken.RefreshToken != "" {
|
||||
l.RefreshToken = newToken.RefreshToken
|
||||
}
|
||||
|
||||
if !newToken.Expiry.IsZero() {
|
||||
l.TokenExpiry = newToken.Expiry.Unix()
|
||||
}
|
||||
|
||||
// Save updated login to config
|
||||
return UpdateLogin(l)
|
||||
}
|
||||
|
||||
// Client returns a client to operate Gitea API. You may provide additional modifiers
|
||||
// for the client like gitea.SetBasicAuth() for customization
|
||||
func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
|
||||
// Check if token needs refreshing (if we have a refresh token and expiry time)
|
||||
if l.RefreshToken != "" && l.TokenExpiry > 0 && time.Now().Unix() > l.TokenExpiry {
|
||||
// Since we can't directly call auth.RefreshAccessToken due to import cycles,
|
||||
// we'll implement the token refresh logic here.
|
||||
// Create an expired Token object
|
||||
expiredToken := &oauth2.Token{
|
||||
AccessToken: l.Token,
|
||||
RefreshToken: l.RefreshToken,
|
||||
// Set expiry in the past to force refresh
|
||||
Expiry: time.Unix(l.TokenExpiry, 0),
|
||||
}
|
||||
|
||||
// Set up the OAuth2 config
|
||||
ctx := context.Background()
|
||||
|
||||
// Create HTTP client with proper insecure settings
|
||||
httpClient := &http.Client{}
|
||||
if l.Insecure {
|
||||
httpClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
}
|
||||
}
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
|
||||
// Configure the OAuth2 endpoints
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: "d57cb8c4-630c-4168-8324-ec79935e18d4", // defaultClientID from modules/auth/oauth.go
|
||||
Endpoint: oauth2.Endpoint{
|
||||
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL),
|
||||
},
|
||||
}
|
||||
|
||||
// Refresh the token
|
||||
newToken, err := oauth2Config.TokenSource(ctx, expiredToken).Token()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name)
|
||||
}
|
||||
// Update login with new token information
|
||||
l.Token = newToken.AccessToken
|
||||
|
||||
if newToken.RefreshToken != "" {
|
||||
l.RefreshToken = newToken.RefreshToken
|
||||
}
|
||||
|
||||
if !newToken.Expiry.IsZero() {
|
||||
l.TokenExpiry = newToken.Expiry.Unix()
|
||||
}
|
||||
|
||||
// Save updated login to config
|
||||
if err := UpdateLogin(l); err != nil {
|
||||
log.Fatalf("Failed to save refreshed token: %s\n", err)
|
||||
}
|
||||
// Refresh OAuth token if expired or near expiry
|
||||
if err := l.RefreshOAuthTokenIfNeeded(); err != nil {
|
||||
log.Fatalf("Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{}
|
||||
|
||||
Reference in New Issue
Block a user