fj/internal/api/client.go

247 lines
6.5 KiB
Go
Raw Normal View History

2025-12-08 09:49:07 +01:00
package api
import (
2026-01-16 10:51:37 +01:00
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
2025-12-08 09:49:07 +01:00
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/config"
2025-12-08 09:49:07 +01:00
)
fix(cmd): correctness + audit hardening across cmd/ + internal/ Addresses audit findings from a tri-partite review (codex + 2 Claude agents). Multiple distinct fixes here because they touched overlapping files; happy to split via interactive rebase if a reviewer prefers. ## Correctness bugs (HIGH) * `--config` is now actually honored. cmd/root.initConfig fed Viper but every command that mattered loaded config via `internal/config.Load()` which always read the default path. Added `config.SetExplicitConfigPath` consulted by `GetConfigPath`; `--config other.yaml auth login` now writes to other.yaml. - internal/config/config.go, cmd/root.go * `--json` now works on `fj run …`, `fj workflow …`, and `fj wiki view`. cmd/aliases.go registered `--json` as a Bool but the handlers call `wantJSON()` which does `GetString("json")` and silently ignores the type-error return. cmd/wiki.go did the inverse (`GetBool("json")` against a string-registered flag). Both now use `addJSONFlags`/`wantJSON`/ `outputJSON` consistently. - cmd/aliases.go, cmd/wiki.go * `fj api` no longer lets endpoints escape the /api/v1 base via path-traversal. `fj api '/../admin/users'` previously normalized to `/admin/users` because `http.NewRequest` resolves `..` segments — silently sending authenticated traffic to non-API routes. Endpoint is now parsed, `..` segments are rejected, and JoinPath is used. - cmd/api.go ## Design rework (BREAKING — gets rid of the `--json=fields` quirk) * `--json` flag rebuilt from a string-with-NoOptDefVal=" " sentinel into a plain Bool. `--json-fields` keeps comma-separated projection. The two are mutually exclusive (`MarkFlagsMutuallyExclusive`). `--jq` composes with either or neither. The previous design produced a `--json string[=" "]` in --help and required `--json=fields` (with literal "=") because `--json fields` was parsed as the bare flag plus a positional. Gone. - cmd/json.go: addJSONFlags / wantJSON / outputJSON - cmd/api.go: example block reflects the new shape Migration: `--json=fields` → `--json-fields fields`. Bare `--json` still means "everything as JSON". * `fj api` now uses `internal/api.SharedHTTPClient` (30 s timeout, pooled) instead of constructing a zero-value `&http.Client{}` with no timeout. A hung Forgejo no longer pins the CLI indefinitely. Response body is also bounded by `io.LimitReader` at 64 MB to prevent OOM-on-self. - internal/api/client.go (export SharedHTTPClient), cmd/api.go * `--hostname` declared as a persistent flag on rootCmd is now the only declaration. cmd/auth.go re-declared `--hostname` on three subcommands, shadowing the persistent flag — meaning `fj --hostname=X auth login` and `fj auth login --hostname=X` went through different code paths (viper read vs. local flag read). Local declarations removed. - cmd/auth.go ## Hardening (MEDIUM/LOW) * `--token` on `auth login` now emits a stderr warning when used, since it puts the PAT on argv (visible in `ps auxe`/shell history). Flag not removed — too disruptive — but discoverable now. - cmd/auth.go * Error handling no longer regex-matches "401"/"403" against rendered error strings (would have triggered "auth login" hint for an error that just mentioned issue #403). Now relies on typed `*api.APIError`. Hints moved to a separate `Hint` field on `CLIError`, so JSON-error consumers get clean structure and the human renderer still appends "\nHint: …". - cmd/errors.go * `migrateConfigDir` now opens dst with `O_TRUNC` instead of just `O_CREATE|O_WRONLY`. Previously a partially-pre-existing dst file would have legacy contents overwrite a prefix and leave stale tail bytes — silent YAML/token corruption. - cmd/root.go (extracted into copyOneConfigFile with proper close handling) * Config dir created with mode 0700 instead of 0755. `initConfig` warns on stderr if the resolved config file is world/group readable (`mode & 0o077 != 0`); doesn't fail-close. - cmd/root.go * Network errors (`no such host`, `connection refused`, `i/o timeout`) now return a structured `CLIError` with code `ErrNetworkError` and a hint, instead of a fmt.Errorf chain. - cmd/errors.go Verified: `go build ./...` and `go test ./...` clean. Live integration tested against forgejo.zerova.net. Out of scope, deferred to follow-up commits: - Pagination unification across `repo list`/`pr list`/`issue list` (only `release list` walks pages today; others silently truncate). - `fj api --paginate` to follow pages like `gh api --paginate`. - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees.
2026-05-02 15:41:48 -06:00
// SharedHTTPClient is the package-wide HTTP client. Exported so other
// packages (notably cmd/api.go) can reuse the same timeout and connection
// pooling instead of constructing zero-value clients with no timeout.
var SharedHTTPClient = &http.Client{
Timeout: 30 * time.Second,
}
fix(cmd): correctness + audit hardening across cmd/ + internal/ Addresses audit findings from a tri-partite review (codex + 2 Claude agents). Multiple distinct fixes here because they touched overlapping files; happy to split via interactive rebase if a reviewer prefers. ## Correctness bugs (HIGH) * `--config` is now actually honored. cmd/root.initConfig fed Viper but every command that mattered loaded config via `internal/config.Load()` which always read the default path. Added `config.SetExplicitConfigPath` consulted by `GetConfigPath`; `--config other.yaml auth login` now writes to other.yaml. - internal/config/config.go, cmd/root.go * `--json` now works on `fj run …`, `fj workflow …`, and `fj wiki view`. cmd/aliases.go registered `--json` as a Bool but the handlers call `wantJSON()` which does `GetString("json")` and silently ignores the type-error return. cmd/wiki.go did the inverse (`GetBool("json")` against a string-registered flag). Both now use `addJSONFlags`/`wantJSON`/ `outputJSON` consistently. - cmd/aliases.go, cmd/wiki.go * `fj api` no longer lets endpoints escape the /api/v1 base via path-traversal. `fj api '/../admin/users'` previously normalized to `/admin/users` because `http.NewRequest` resolves `..` segments — silently sending authenticated traffic to non-API routes. Endpoint is now parsed, `..` segments are rejected, and JoinPath is used. - cmd/api.go ## Design rework (BREAKING — gets rid of the `--json=fields` quirk) * `--json` flag rebuilt from a string-with-NoOptDefVal=" " sentinel into a plain Bool. `--json-fields` keeps comma-separated projection. The two are mutually exclusive (`MarkFlagsMutuallyExclusive`). `--jq` composes with either or neither. The previous design produced a `--json string[=" "]` in --help and required `--json=fields` (with literal "=") because `--json fields` was parsed as the bare flag plus a positional. Gone. - cmd/json.go: addJSONFlags / wantJSON / outputJSON - cmd/api.go: example block reflects the new shape Migration: `--json=fields` → `--json-fields fields`. Bare `--json` still means "everything as JSON". * `fj api` now uses `internal/api.SharedHTTPClient` (30 s timeout, pooled) instead of constructing a zero-value `&http.Client{}` with no timeout. A hung Forgejo no longer pins the CLI indefinitely. Response body is also bounded by `io.LimitReader` at 64 MB to prevent OOM-on-self. - internal/api/client.go (export SharedHTTPClient), cmd/api.go * `--hostname` declared as a persistent flag on rootCmd is now the only declaration. cmd/auth.go re-declared `--hostname` on three subcommands, shadowing the persistent flag — meaning `fj --hostname=X auth login` and `fj auth login --hostname=X` went through different code paths (viper read vs. local flag read). Local declarations removed. - cmd/auth.go ## Hardening (MEDIUM/LOW) * `--token` on `auth login` now emits a stderr warning when used, since it puts the PAT on argv (visible in `ps auxe`/shell history). Flag not removed — too disruptive — but discoverable now. - cmd/auth.go * Error handling no longer regex-matches "401"/"403" against rendered error strings (would have triggered "auth login" hint for an error that just mentioned issue #403). Now relies on typed `*api.APIError`. Hints moved to a separate `Hint` field on `CLIError`, so JSON-error consumers get clean structure and the human renderer still appends "\nHint: …". - cmd/errors.go * `migrateConfigDir` now opens dst with `O_TRUNC` instead of just `O_CREATE|O_WRONLY`. Previously a partially-pre-existing dst file would have legacy contents overwrite a prefix and leave stale tail bytes — silent YAML/token corruption. - cmd/root.go (extracted into copyOneConfigFile with proper close handling) * Config dir created with mode 0700 instead of 0755. `initConfig` warns on stderr if the resolved config file is world/group readable (`mode & 0o077 != 0`); doesn't fail-close. - cmd/root.go * Network errors (`no such host`, `connection refused`, `i/o timeout`) now return a structured `CLIError` with code `ErrNetworkError` and a hint, instead of a fmt.Errorf chain. - cmd/errors.go Verified: `go build ./...` and `go test ./...` clean. Live integration tested against forgejo.zerova.net. Out of scope, deferred to follow-up commits: - Pagination unification across `repo list`/`pr list`/`issue list` (only `release list` walks pages today; others silently truncate). - `fj api --paginate` to follow pages like `gh api --paginate`. - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees.
2026-05-02 15:41:48 -06:00
// Internal alias kept so existing call sites compile unchanged.
var sharedHTTPClient = SharedHTTPClient
2025-12-08 09:49:07 +01:00
type Client struct {
*gitea.Client
hostname string
token string
2025-12-08 09:49:07 +01:00
}
func NewClient(hostname, token string) (*Client, error) {
if hostname == "" {
hostname = "codeberg.org"
}
client, err := gitea.NewClient("https://"+hostname, gitea.SetToken(token))
if err != nil {
return nil, err
}
return &Client{
Client: client,
hostname: hostname,
token: token,
2025-12-08 09:49:07 +01:00
}, nil
}
func NewClientFromConfig(cfg *config.Config, hostname string, detectedHost string, cwd string) (*Client, error) {
host, err := cfg.GetHost(hostname, detectedHost, cwd)
2025-12-08 09:49:07 +01:00
if err != nil {
return nil, err
}
return NewClient(host.Hostname, host.Token)
}
func (c *Client) Hostname() string {
return c.hostname
}
// GetJSON performs a GET request to the specified path and decodes the JSON response
func (c *Client) GetJSON(path string, result any) error {
baseURL := "https://" + c.hostname
url := baseURL + path
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Set authentication header
if c.token != "" {
req.Header.Set("Authorization", "token "+c.token)
}
req.Header.Set("Accept", "application/json")
resp, err := sharedHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("failed to perform request: %w", err)
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil && err == nil {
err = fmt.Errorf("failed to close response body: %w", closeErr)
}
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("failed to read error response body: %w", readErr)
}
return &APIError{
StatusCode: resp.StatusCode,
Body: string(body),
Message: fmt.Sprintf("API request failed with status %d: %s", resp.StatusCode, string(body)),
}
}
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
2026-01-16 10:51:37 +01:00
return nil
}
// PostJSON performs a POST request to the specified path with JSON body
func (c *Client) PostJSON(path string, body any, result any) error {
_, err := c.DoJSON(http.MethodPost, path, body, result)
return err
}
// DoJSON performs an HTTP request with a JSON body and decodes the JSON response.
// Returns the HTTP status code and any error encountered.
func (c *Client) DoJSON(method string, path string, body any, result any) (int, error) {
2026-01-16 10:51:37 +01:00
baseURL := "https://" + c.hostname
url := baseURL + path
var bodyReader io.Reader
if body != nil {
bodyBytes, err := json.Marshal(body)
if err != nil {
return 0, fmt.Errorf("failed to marshal request body: %w", err)
2026-01-16 10:51:37 +01:00
}
bodyReader = bytes.NewReader(bodyBytes)
}
req, err := http.NewRequest(method, url, bodyReader)
2026-01-16 10:51:37 +01:00
if err != nil {
return 0, fmt.Errorf("failed to create request: %w", err)
2026-01-16 10:51:37 +01:00
}
// Set authentication header
if c.token != "" {
req.Header.Set("Authorization", "token "+c.token)
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
2026-01-16 10:51:37 +01:00
resp, err := sharedHTTPClient.Do(req)
2026-01-16 10:51:37 +01:00
if err != nil {
return 0, fmt.Errorf("failed to perform request: %w", err)
2026-01-16 10:51:37 +01:00
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil && err == nil {
err = fmt.Errorf("failed to close response body: %w", closeErr)
}
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return resp.StatusCode, fmt.Errorf("failed to read error response body: %w", readErr)
}
return resp.StatusCode, &APIError{
StatusCode: resp.StatusCode,
Body: string(bodyBytes),
Message: fmt.Sprintf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)),
}
2026-01-16 10:51:37 +01:00
}
if result != nil && resp.StatusCode != http.StatusNoContent {
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return resp.StatusCode, fmt.Errorf("failed to decode response: %w", err)
2026-01-16 10:51:37 +01:00
}
}
return resp.StatusCode, nil
}
2025-12-09 13:41:08 +01:00
// Token returns the client's authentication token.
func (c *Client) Token() string {
return c.token
}
// DownloadFile performs an authenticated GET request and writes the response body to the given writer.
func (c *Client) DownloadFile(url string, w io.Writer) error {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
if c.token != "" {
req.Header.Set("Authorization", "token "+c.token)
}
resp, err := sharedHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("failed to perform request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("download failed with status %d: %s", resp.StatusCode, string(body))
}
if _, err := io.Copy(w, resp.Body); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
2025-12-09 13:41:08 +01:00
// GetRawLog performs a GET request and returns the raw response body as string
func (c *Client) GetRawLog(url string) (string, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
// Set authentication header
if c.token != "" {
req.Header.Set("Authorization", "token "+c.token)
}
resp, err := sharedHTTPClient.Do(req)
2025-12-09 13:41:08 +01:00
if err != nil {
return "", fmt.Errorf("failed to perform request: %w", err)
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil && err == nil {
err = fmt.Errorf("failed to close response body: %w", closeErr)
}
}()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return "", fmt.Errorf("failed to read error response body: %w", readErr)
}
return "", &APIError{
StatusCode: resp.StatusCode,
Body: string(body),
Message: fmt.Sprintf("API request failed with status %d: %s", resp.StatusCode, string(body)),
}
2025-12-09 13:41:08 +01:00
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
return string(bodyBytes), nil
}