package gitea import ( "context" "encoding/json" "errors" "fmt" "html" "io" "net/http" "net/url" "regexp" "strconv" "strings" "time" ) var ErrTokenNotConfigured = errors.New("gitea API token is not configured") var ( signedUserIDMeta = regexp.MustCompile(`(?is)`) signedUserLoginMeta = regexp.MustCompile(`(?is)`) ) 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 } // Gitea's API user endpoint requires an API token and intentionally rejects // browser sessions. The protected profile settings page includes Hop's custom // server-rendered user identity metadata for the active Gitea session. u := c.baseURL.JoinPath("user", "settings") 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", "text/html") resp, err := c.doWithoutRedirect(req) if err != nil { return User{}, false, fmt.Errorf("load authenticated user: %w", err) } defer resp.Body.Close() switch resp.StatusCode { case http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect, http.StatusUnauthorized, http.StatusForbidden: return User{}, false, nil case http.StatusOK: // Continue below. default: return User{}, false, fmt.Errorf("gitea authenticated user lookup returned %s", resp.Status) } body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) if err != nil { return User{}, false, fmt.Errorf("read authenticated user settings: %w", err) } idMatch := signedUserIDMeta.FindSubmatch(body) loginMatch := signedUserLoginMeta.FindSubmatch(body) if len(idMatch) != 2 || len(loginMatch) != 3 { return User{}, false, errors.New("gitea settings page did not identify the signed-in user") } id, err := strconv.ParseInt(string(idMatch[1]), 10, 64) if err != nil || id <= 0 { return User{}, false, errors.New("gitea settings page returned an invalid user ID") } login := string(loginMatch[1]) if login == "" { login = string(loginMatch[2]) } login = strings.TrimSpace(html.UnescapeString(login)) if login == "" { return User{}, false, errors.New("gitea settings page returned an empty username") } return User{ID: id, Login: login}, true, nil } // ViewerCanReadRepository checks a browser's existing Gitea session against // the repository web route. Gitea API routes require API tokens and do not // recognize browser sessions. func (c *Client) ViewerCanReadRepository(ctx context.Context, cookie, owner, name string) (bool, error) { if strings.TrimSpace(cookie) == "" { return false, nil } u := c.baseURL.JoinPath(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", "text/html") resp, err := c.doWithoutRedirect(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.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound: return false, nil default: return false, fmt.Errorf("gitea viewer repository check returned %s", resp.Status) } } func (c *Client) doWithoutRedirect(req *http.Request) (*http.Response, error) { client := *c.http client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } return client.Do(req) }