mirror of
https://gitea.com/gitea/tea.git
synced 2026-02-21 22:03: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
2
Makefile
2
Makefile
@@ -7,7 +7,7 @@ export PATH := $($(GO) env GOPATH)/bin:$(PATH)
|
|||||||
GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go")
|
GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go")
|
||||||
|
|
||||||
# Tool packages with pinned versions
|
# Tool packages with pinned versions
|
||||||
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.7.0
|
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2
|
||||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0
|
||||||
|
|
||||||
ifneq ($(DRONE_TAG),)
|
ifneq ($(DRONE_TAG),)
|
||||||
|
|||||||
274
cmd/api.go
Normal file
274
cmd/api.go
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdctx "context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.gitea.io/tea/cmd/flags"
|
||||||
|
"code.gitea.io/tea/modules/api"
|
||||||
|
"code.gitea.io/tea/modules/context"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CmdApi represents the api command
|
||||||
|
var CmdApi = cli.Command{
|
||||||
|
Name: "api",
|
||||||
|
Usage: "Make an authenticated API request",
|
||||||
|
Description: `Makes an authenticated HTTP request to the Gitea API and prints the response.
|
||||||
|
|
||||||
|
The endpoint argument is the path to the API endpoint, which will be prefixed
|
||||||
|
with /api/v1/ if it doesn't start with /api/ or http(s)://.
|
||||||
|
|
||||||
|
Placeholders like {owner} and {repo} in the endpoint will be replaced with
|
||||||
|
values from the current repository context.
|
||||||
|
|
||||||
|
Use -f for string fields and -F for typed fields (numbers, booleans, null).
|
||||||
|
With -F, prefix value with @ to read from file (@- for stdin).`,
|
||||||
|
ArgsUsage: "<endpoint>",
|
||||||
|
Action: runApi,
|
||||||
|
Flags: append([]cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "method",
|
||||||
|
Aliases: []string{"X"},
|
||||||
|
Usage: "HTTP method (GET, POST, PUT, PATCH, DELETE)",
|
||||||
|
Value: "GET",
|
||||||
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "field",
|
||||||
|
Aliases: []string{"f"},
|
||||||
|
Usage: "Add a string field to the request body (key=value)",
|
||||||
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "Field",
|
||||||
|
Aliases: []string{"F"},
|
||||||
|
Usage: "Add a typed field to the request body (key=value, @file, or @- for stdin)",
|
||||||
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "header",
|
||||||
|
Aliases: []string{"H"},
|
||||||
|
Usage: "Add a custom header (key:value)",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "include",
|
||||||
|
Aliases: []string{"i"},
|
||||||
|
Usage: "Include HTTP status and response headers in output (written to stderr)",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "output",
|
||||||
|
Aliases: []string{"o"},
|
||||||
|
Usage: "Write response body to file instead of stdout (use '-' for stdout)",
|
||||||
|
},
|
||||||
|
}, flags.LoginRepoFlags...),
|
||||||
|
}
|
||||||
|
|
||||||
|
func runApi(_ stdctx.Context, cmd *cli.Command) error {
|
||||||
|
ctx := context.InitCommand(cmd)
|
||||||
|
|
||||||
|
// Get the endpoint argument
|
||||||
|
if cmd.NArg() < 1 {
|
||||||
|
return fmt.Errorf("endpoint argument required")
|
||||||
|
}
|
||||||
|
endpoint := cmd.Args().First()
|
||||||
|
|
||||||
|
// Expand placeholders in endpoint
|
||||||
|
endpoint = expandPlaceholders(endpoint, ctx)
|
||||||
|
|
||||||
|
// Parse headers
|
||||||
|
headers := make(map[string]string)
|
||||||
|
for _, h := range cmd.StringSlice("header") {
|
||||||
|
parts := strings.SplitN(h, ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return fmt.Errorf("invalid header format: %q (expected key:value)", h)
|
||||||
|
}
|
||||||
|
headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build request body from fields
|
||||||
|
var body io.Reader
|
||||||
|
stringFields := cmd.StringSlice("field")
|
||||||
|
typedFields := cmd.StringSlice("Field")
|
||||||
|
|
||||||
|
if len(stringFields) > 0 || len(typedFields) > 0 {
|
||||||
|
bodyMap := make(map[string]any)
|
||||||
|
|
||||||
|
// Process string fields (-f)
|
||||||
|
for _, f := range stringFields {
|
||||||
|
parts := strings.SplitN(f, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return fmt.Errorf("invalid field format: %q (expected key=value)", f)
|
||||||
|
}
|
||||||
|
bodyMap[parts[0]] = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process typed fields (-F)
|
||||||
|
for _, f := range typedFields {
|
||||||
|
parts := strings.SplitN(f, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return fmt.Errorf("invalid field format: %q (expected key=value)", f)
|
||||||
|
}
|
||||||
|
key := parts[0]
|
||||||
|
value := parts[1]
|
||||||
|
|
||||||
|
parsedValue, err := parseTypedValue(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse field %q: %w", key, err)
|
||||||
|
}
|
||||||
|
bodyMap[key] = parsedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(bodyMap)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encode request body: %w", err)
|
||||||
|
}
|
||||||
|
body = strings.NewReader(string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create API client and make request
|
||||||
|
client := api.NewClient(ctx.Login)
|
||||||
|
method := strings.ToUpper(cmd.String("method"))
|
||||||
|
|
||||||
|
resp, err := client.Do(method, endpoint, body, headers)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Print headers to stderr if requested (so redirects/pipes work correctly)
|
||||||
|
if cmd.Bool("include") {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s %s\n", resp.Proto, resp.Status)
|
||||||
|
for key, values := range resp.Header {
|
||||||
|
for _, value := range values {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %s\n", key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine output destination
|
||||||
|
outputPath := cmd.String("output")
|
||||||
|
forceStdout := outputPath == "-"
|
||||||
|
outputToStdout := outputPath == "" || forceStdout
|
||||||
|
|
||||||
|
// Check for binary output to terminal (skip warning if user explicitly forced stdout)
|
||||||
|
if outputToStdout && !forceStdout && term.IsTerminal(int(os.Stdout.Fd())) && !isTextContentType(resp.Header.Get("Content-Type")) {
|
||||||
|
fmt.Fprintln(os.Stderr, "Warning: Binary output detected. Use '-o <file>' to save to a file,")
|
||||||
|
fmt.Fprintln(os.Stderr, "or '-o -' to force output to terminal.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var output io.Writer = os.Stdout
|
||||||
|
if !outputToStdout {
|
||||||
|
file, err := os.Create(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
output = file
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy response body to output
|
||||||
|
_, err = io.Copy(output, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add newline for better terminal display
|
||||||
|
if outputToStdout && term.IsTerminal(int(os.Stdout.Fd())) {
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTypedValue parses a value for -F flag, handling:
|
||||||
|
// - @filename: read content from file
|
||||||
|
// - @-: read content from stdin
|
||||||
|
// - true/false: boolean
|
||||||
|
// - null: nil
|
||||||
|
// - numbers: int or float
|
||||||
|
// - otherwise: string
|
||||||
|
func parseTypedValue(value string) (any, error) {
|
||||||
|
// Handle file references
|
||||||
|
if strings.HasPrefix(value, "@") {
|
||||||
|
filename := value[1:]
|
||||||
|
var content []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if filename == "-" {
|
||||||
|
content, err = io.ReadAll(os.Stdin)
|
||||||
|
} else {
|
||||||
|
content, err = os.ReadFile(filename)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read %q: %w", value, err)
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(string(content), "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle null
|
||||||
|
if value == "null" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle booleans
|
||||||
|
if value == "true" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if value == "false" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle integers
|
||||||
|
if i, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle floats
|
||||||
|
if f, err := strconv.ParseFloat(value, 64); err == nil {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to string
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTextContentType returns true if the content type indicates text data
|
||||||
|
func isTextContentType(contentType string) bool {
|
||||||
|
if contentType == "" {
|
||||||
|
return true // assume text if unknown
|
||||||
|
}
|
||||||
|
contentType = strings.ToLower(strings.Split(contentType, ";")[0]) // strip charset
|
||||||
|
|
||||||
|
return strings.HasPrefix(contentType, "text/") ||
|
||||||
|
strings.Contains(contentType, "json") ||
|
||||||
|
strings.Contains(contentType, "xml") ||
|
||||||
|
strings.Contains(contentType, "javascript") ||
|
||||||
|
strings.Contains(contentType, "yaml") ||
|
||||||
|
strings.Contains(contentType, "toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandPlaceholders replaces {owner}, {repo}, and {branch} in the endpoint
|
||||||
|
func expandPlaceholders(endpoint string, ctx *context.TeaContext) string {
|
||||||
|
endpoint = strings.ReplaceAll(endpoint, "{owner}", ctx.Owner)
|
||||||
|
endpoint = strings.ReplaceAll(endpoint, "{repo}", ctx.Repo)
|
||||||
|
|
||||||
|
// Get current branch if available
|
||||||
|
if ctx.LocalRepo != nil {
|
||||||
|
if branch, err := ctx.LocalRepo.Head(); err == nil {
|
||||||
|
branchName := branch.Name().Short()
|
||||||
|
endpoint = strings.ReplaceAll(endpoint, "{branch}", branchName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return endpoint
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ func App() *cli.Command {
|
|||||||
|
|
||||||
&CmdAdmin,
|
&CmdAdmin,
|
||||||
|
|
||||||
|
&CmdApi,
|
||||||
&CmdGenerateManPage,
|
&CmdGenerateManPage,
|
||||||
},
|
},
|
||||||
EnableShellCompletion: true,
|
EnableShellCompletion: true,
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.gitea.io/tea/modules/auth"
|
|
||||||
"code.gitea.io/tea/modules/config"
|
"code.gitea.io/tea/modules/config"
|
||||||
"code.gitea.io/tea/modules/task"
|
"code.gitea.io/tea/modules/task"
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
@@ -59,6 +57,13 @@ var CmdLoginHelper = cli.Command{
|
|||||||
{
|
{
|
||||||
Name: "get",
|
Name: "get",
|
||||||
Description: "Get token to auth",
|
Description: "Get token to auth",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "login",
|
||||||
|
Aliases: []string{"l"},
|
||||||
|
Usage: "Use a specific login",
|
||||||
|
},
|
||||||
|
},
|
||||||
Action: func(_ context.Context, cmd *cli.Command) error {
|
Action: func(_ context.Context, cmd *cli.Command) error {
|
||||||
wants := map[string]string{}
|
wants := map[string]string{}
|
||||||
s := bufio.NewScanner(os.Stdin)
|
s := bufio.NewScanner(os.Stdin)
|
||||||
@@ -93,10 +98,21 @@ var CmdLoginHelper = cli.Command{
|
|||||||
wants["protocol"] = "http"
|
wants["protocol"] = "http"
|
||||||
}
|
}
|
||||||
|
|
||||||
userConfig := config.GetLoginByHost(wants["host"])
|
// Use --login flag if provided, otherwise fall back to host lookup
|
||||||
|
var userConfig *config.Login
|
||||||
|
if loginName := cmd.String("login"); loginName != "" {
|
||||||
|
userConfig = config.GetLoginByName(loginName)
|
||||||
if userConfig == nil {
|
if userConfig == nil {
|
||||||
log.Fatal("host not exists")
|
log.Fatalf("Login '%s' not found", loginName)
|
||||||
} else if len(userConfig.Token) == 0 {
|
}
|
||||||
|
} else {
|
||||||
|
userConfig = config.GetLoginByHost(wants["host"])
|
||||||
|
if userConfig == nil {
|
||||||
|
log.Fatalf("No login found for host '%s'", wants["host"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(userConfig.Token) == 0 {
|
||||||
log.Fatal("User no set")
|
log.Fatal("User no set")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,20 +121,11 @@ var CmdLoginHelper = cli.Command{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if userConfig.TokenExpiry > 0 && time.Now().Unix() > userConfig.TokenExpiry {
|
// Refresh token if expired or near expiry (updates userConfig in place)
|
||||||
// Token is expired, refresh it
|
if err = userConfig.RefreshOAuthTokenIfNeeded(); err != nil {
|
||||||
err = auth.RefreshAccessToken(userConfig)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Once token is refreshed, get the latest from the updated config
|
|
||||||
refreshedConfig := config.GetLoginByHost(wants["host"])
|
|
||||||
if refreshedConfig != nil {
|
|
||||||
userConfig = refreshedConfig
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = fmt.Fprintf(os.Stdout, "protocol=%s\nhost=%s\nusername=%s\npassword=%s\n", host.Scheme, host.Host, userConfig.User, userConfig.Token)
|
_, err = fmt.Fprintf(os.Stdout, "protocol=%s\nhost=%s\nusername=%s\npassword=%s\n", host.Scheme, host.Host, userConfig.User, userConfig.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
22
docs/CLI.md
22
docs/CLI.md
@@ -1712,3 +1712,25 @@ List Users
|
|||||||
**--remote, -R**="": Discover Gitea login from remote. Optional
|
**--remote, -R**="": Discover Gitea login from remote. Optional
|
||||||
|
|
||||||
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
|
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
|
||||||
|
|
||||||
|
## api
|
||||||
|
|
||||||
|
Make an authenticated API request
|
||||||
|
|
||||||
|
**--Field, -F**="": Add a typed field to the request body (key=value, @file, or @- for stdin)
|
||||||
|
|
||||||
|
**--field, -f**="": Add a string field to the request body (key=value)
|
||||||
|
|
||||||
|
**--header, -H**="": Add a custom header (key:value)
|
||||||
|
|
||||||
|
**--include, -i**: Include HTTP status and response headers in output (written to stderr)
|
||||||
|
|
||||||
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
|
|
||||||
|
**--method, -X**="": HTTP method (GET, POST, PUT, PATCH, DELETE) (default: "GET")
|
||||||
|
|
||||||
|
**--output, -o**="": Write response body to file instead of stdout (use '-' for stdout)
|
||||||
|
|
||||||
|
**--remote, -R**="": Discover Gitea login from remote. Optional
|
||||||
|
|
||||||
|
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
|
||||||
|
|||||||
16
main.go
16
main.go
@@ -16,7 +16,7 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
app := cmd.App()
|
app := cmd.App()
|
||||||
app.Flags = append(app.Flags, debug.CliFlag())
|
app.Flags = append(app.Flags, debug.CliFlag())
|
||||||
err := app.Run(context.Background(), os.Args)
|
err := app.Run(context.Background(), preprocessArgs(os.Args))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// app.Run already exits for errors implementing ErrorCoder,
|
// app.Run already exits for errors implementing ErrorCoder,
|
||||||
// so we only handle generic errors with code 1 here.
|
// so we only handle generic errors with code 1 here.
|
||||||
@@ -24,3 +24,17 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// preprocessArgs normalizes command-line arguments.
|
||||||
|
// Converts "-o-" to "-o -" for the api command's output flag.
|
||||||
|
func preprocessArgs(args []string) []string {
|
||||||
|
result := make([]string, 0, len(args)+1)
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg == "-o-" {
|
||||||
|
result = append(result, "-o", "-")
|
||||||
|
} else {
|
||||||
|
result = append(result, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
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
|
// Constants for OAuth2 PKCE flow
|
||||||
const (
|
const (
|
||||||
// default client ID included in most Gitea instances
|
|
||||||
defaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4"
|
|
||||||
|
|
||||||
// default scopes to request
|
// default scopes to request
|
||||||
defaultScopes = "admin,user,issue,misc,notification,organization,package,repository"
|
defaultScopes = "admin,user,issue,misc,notification,organization,package,repository"
|
||||||
|
|
||||||
@@ -65,7 +62,7 @@ func OAuthLoginWithOptions(name, giteaURL string, insecure bool) error {
|
|||||||
Name: name,
|
Name: name,
|
||||||
URL: giteaURL,
|
URL: giteaURL,
|
||||||
Insecure: insecure,
|
Insecure: insecure,
|
||||||
ClientID: defaultClientID,
|
ClientID: config.DefaultClientID,
|
||||||
RedirectURL: fmt.Sprintf("http://%s:%d", redirectHost, redirectPort),
|
RedirectURL: fmt.Sprintf("http://%s:%d", redirectHost, redirectPort),
|
||||||
Port: redirectPort,
|
Port: redirectPort,
|
||||||
}
|
}
|
||||||
@@ -82,7 +79,7 @@ func OAuthLoginWithFullOptions(opts OAuthOptions) error {
|
|||||||
|
|
||||||
// Set defaults if needed
|
// Set defaults if needed
|
||||||
if opts.ClientID == "" {
|
if opts.ClientID == "" {
|
||||||
opts.ClientID = defaultClientID
|
opts.ClientID = config.DefaultClientID
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the redirect URL is specified, parse it to extract port if needed
|
// 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
|
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 {
|
func RefreshAccessToken(login *config.Login) error {
|
||||||
if login.RefreshToken == "" {
|
return login.RefreshOAuthToken()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ import (
|
|||||||
"golang.org/x/oauth2"
|
"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
|
// Login represents a login to a gitea server, you even could add multiple logins for one gitea server
|
||||||
type Login struct {
|
type Login struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
@@ -129,21 +136,31 @@ func GetLoginByToken(token string) *Login {
|
|||||||
|
|
||||||
// GetLoginByHost finds a login by it's server URL
|
// GetLoginByHost finds a login by it's server URL
|
||||||
func GetLoginByHost(host string) *Login {
|
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()
|
err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, l := range config.Logins {
|
var matches []*Login
|
||||||
loginURL, err := url.Parse(l.URL)
|
for i := range config.Logins {
|
||||||
|
loginURL, err := url.Parse(config.Logins[i].URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if loginURL.Host == host {
|
if loginURL.Host == host {
|
||||||
return &l
|
matches = append(matches, &config.Logins[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return matches
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteLogin delete a login by name from config
|
// DeleteLogin delete a login by name from config
|
||||||
@@ -208,48 +225,58 @@ func UpdateLogin(login *Login) error {
|
|||||||
return saveConfig()
|
return saveConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client returns a client to operate Gitea API. You may provide additional modifiers
|
// RefreshOAuthTokenIfNeeded refreshes the OAuth token if it's expired or near expiry.
|
||||||
// for the client like gitea.SetBasicAuth() for customization
|
// Returns nil without doing anything if no refresh is needed.
|
||||||
func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
|
func (l *Login) RefreshOAuthTokenIfNeeded() error {
|
||||||
// Check if token needs refreshing (if we have a refresh token and expiry time)
|
if l.RefreshToken == "" || l.TokenExpiry == 0 {
|
||||||
if l.RefreshToken != "" && l.TokenExpiry > 0 && time.Now().Unix() > l.TokenExpiry {
|
return nil
|
||||||
// Since we can't directly call auth.RefreshAccessToken due to import cycles,
|
}
|
||||||
// we'll implement the token refresh logic here.
|
expiryTime := time.Unix(l.TokenExpiry, 0)
|
||||||
// Create an expired Token object
|
if time.Now().Add(TokenRefreshThreshold).After(expiryTime) {
|
||||||
expiredToken := &oauth2.Token{
|
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,
|
AccessToken: l.Token,
|
||||||
RefreshToken: l.RefreshToken,
|
RefreshToken: l.RefreshToken,
|
||||||
// Set expiry in the past to force refresh
|
|
||||||
Expiry: time.Unix(l.TokenExpiry, 0),
|
Expiry: time.Unix(l.TokenExpiry, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up the OAuth2 config
|
// Set up the OAuth2 config
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create HTTP client with proper insecure settings
|
// Create HTTP client, respecting the login's TLS settings
|
||||||
httpClient := &http.Client{}
|
httpClient := &http.Client{
|
||||||
if l.Insecure {
|
|
||||||
httpClient = &http.Client{
|
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: l.Insecure},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||||
|
|
||||||
// Configure the OAuth2 endpoints
|
// Configure the OAuth2 endpoints
|
||||||
oauth2Config := &oauth2.Config{
|
oauth2Config := &oauth2.Config{
|
||||||
ClientID: "d57cb8c4-630c-4168-8324-ec79935e18d4", // defaultClientID from modules/auth/oauth.go
|
ClientID: DefaultClientID,
|
||||||
Endpoint: oauth2.Endpoint{
|
Endpoint: oauth2.Endpoint{
|
||||||
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL),
|
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the token
|
// Refresh the token
|
||||||
newToken, err := oauth2Config.TokenSource(ctx, expiredToken).Token()
|
newToken, err := oauth2Config.TokenSource(ctx, currentToken).Token()
|
||||||
if err != nil {
|
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)
|
return fmt.Errorf("failed to refresh token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update login with new token information
|
// Update login with new token information
|
||||||
l.Token = newToken.AccessToken
|
l.Token = newToken.AccessToken
|
||||||
|
|
||||||
@@ -262,9 +289,15 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save updated login to config
|
// Save updated login to config
|
||||||
if err := UpdateLogin(l); err != nil {
|
return UpdateLogin(l)
|
||||||
log.Fatalf("Failed to save refreshed token: %s\n", err)
|
}
|
||||||
}
|
|
||||||
|
// 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 {
|
||||||
|
// 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{}
|
httpClient := &http.Client{}
|
||||||
|
|||||||
Reference in New Issue
Block a user