package gitea import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "time" ) var ErrTokenNotConfigured = errors.New("gitea API token is not configured") type Repository struct { ID int64 `json:"id"` Owner Owner `json:"owner"` Name string `json:"name"` FullName string `json:"full_name"` CloneURL string `json:"clone_url"` DefaultBranch string `json:"default_branch"` } type Owner struct { Login string `json:"login"` Username string `json:"username"` } type User struct { ID int64 `json:"id"` Login string `json:"login"` Username string `json:"username"` } func (u User) LoginName() string { if u.Login != "" { return u.Login } return u.Username } func (r Repository) OwnerName() string { if r.Owner.Login != "" { return r.Owner.Login } return r.Owner.Username } type Client struct { baseURL *url.URL token string http *http.Client } func NewClient(baseURL, token string) (*Client, error) { u, err := url.Parse(strings.TrimRight(baseURL, "/")) if err != nil || u.Scheme == "" || u.Host == "" { return nil, fmt.Errorf("invalid GITEA_BASE_URL %q", baseURL) } return &Client{baseURL: u, token: token, http: &http.Client{Timeout: 10 * time.Second}}, nil } func (c *Client) Repository(ctx context.Context, owner, name string) (Repository, error) { if c.token == "" { return Repository{}, ErrTokenNotConfigured } u := c.baseURL.JoinPath("api", "v1", "repos", owner, name) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return Repository{}, err } req.Header.Set("Authorization", "token "+c.token) req.Header.Set("Accept", "application/json") resp, err := c.http.Do(req) if err != nil { return Repository{}, fmt.Errorf("request repository: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return Repository{}, fmt.Errorf("gitea repository lookup returned %s", resp.Status) } var repo Repository if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil { return Repository{}, fmt.Errorf("decode repository: %w", err) } if repo.ID <= 0 || repo.OwnerName() == "" || repo.Name == "" { return Repository{}, errors.New("gitea returned an incomplete repository") } return repo, nil } func (c *Client) User(ctx context.Context, login string) (User, error) { if c.token == "" { return User{}, ErrTokenNotConfigured } u := c.baseURL.JoinPath("api", "v1", "users", login) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return User{}, err } req.Header.Set("Authorization", "token "+c.token) req.Header.Set("Accept", "application/json") resp, err := c.http.Do(req) if err != nil { return User{}, fmt.Errorf("request user: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return User{}, fmt.Errorf("gitea user lookup returned %s", resp.Status) } var user User if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { return User{}, fmt.Errorf("decode user: %w", err) } if user.ID <= 0 || user.LoginName() == "" { return User{}, errors.New("gitea returned an incomplete user") } return user, nil } func (c *Client) AuthenticatedUser(ctx context.Context, cookie string) (User, bool, error) { if strings.TrimSpace(cookie) == "" { return User{}, false, nil } u := c.baseURL.JoinPath("api", "v1", "user") req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return User{}, false, err } req.Header.Set("Cookie", cookie) req.Header.Set("Accept", "application/json") resp, err := c.http.Do(req) if err != nil { return User{}, false, fmt.Errorf("load authenticated user: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return User{}, false, nil } if resp.StatusCode != http.StatusOK { return User{}, false, fmt.Errorf("gitea authenticated user lookup returned %s", resp.Status) } var user User if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { return User{}, false, fmt.Errorf("decode authenticated user: %w", err) } if user.ID <= 0 || user.LoginName() == "" { return User{}, false, errors.New("gitea returned an incomplete authenticated user") } return user, true, nil } // ViewerCanReadRepository checks a browser's existing Gitea session before // returning prompt data. Prompts may contain sensitive intent, so the Hop API // must never turn a private repository into a public metadata feed. func (c *Client) ViewerCanReadRepository(ctx context.Context, cookie, owner, name string) (bool, error) { if strings.TrimSpace(cookie) == "" { return false, nil } u := c.baseURL.JoinPath("api", "v1", "repos", owner, name) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return false, err } req.Header.Set("Cookie", cookie) req.Header.Set("Accept", "application/json") resp, err := c.http.Do(req) if err != nil { return false, fmt.Errorf("check viewer repository access: %w", err) } defer resp.Body.Close() switch resp.StatusCode { case http.StatusOK: return true, nil case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound: return false, nil default: return false, fmt.Errorf("gitea viewer repository check returned %s", resp.Status) } }