package gitea import ( "errors" "fmt" "net/url" "strings" "time" "github.com/rs/zerolog/log" "github.com/valyala/fasthttp" "github.com/valyala/fastjson" ) const ( giteaAPIRepos = "/api/v1/repos/" giteaObjectTypeHeader = "X-Gitea-Object-Type" ) var ErrorNotFound = errors.New("not found") type Client struct { giteaRoot string giteaAPIToken string fastClient *fasthttp.Client infoTimeout time.Duration contentTimeout time.Duration followSymlinks bool supportLFS bool } // TODO: once golang v1.19 is min requirement, we can switch to 'JoinPath()' of 'net/url' package func joinURL(baseURL string, paths ...string) string { p := make([]string, 0, len(paths)) for i := range paths { path := strings.TrimSpace(paths[i]) path = strings.Trim(path, "/") if len(path) != 0 { p = append(p, path) } } return baseURL + "/" + strings.Join(p, "/") } func NewClient(giteaRoot, giteaAPIToken string, followSymlinks, supportLFS bool) (*Client, error) { rootURL, err := url.Parse(giteaRoot) giteaRoot = strings.Trim(rootURL.String(), "/") return &Client{ giteaRoot: giteaRoot, giteaAPIToken: giteaAPIToken, infoTimeout: 5 * time.Second, contentTimeout: 10 * time.Second, fastClient: getFastHTTPClient(), followSymlinks: followSymlinks, supportLFS: supportLFS, }, err } func (client *Client) GiteaRawContent(targetOwner, targetRepo, ref, resource string) ([]byte, error) { resp, err := client.ServeRawContent(targetOwner, targetRepo, ref, resource) if err != nil { return nil, err } return resp.Body(), nil } func (client *Client) ServeRawContent(targetOwner, targetRepo, ref, resource string) (*fasthttp.Response, error) { var apiURL string if client.supportLFS { apiURL = joinURL(client.giteaRoot, giteaAPIRepos, targetOwner, targetRepo, "media", resource+"?ref="+url.QueryEscape(ref)) } else { apiURL = joinURL(client.giteaRoot, giteaAPIRepos, targetOwner, targetRepo, "raw", resource+"?ref="+url.QueryEscape(ref)) } resp, err := client.do(client.contentTimeout, apiURL) if err != nil { return nil, err } if err != nil { return nil, err } switch resp.StatusCode() { case fasthttp.StatusOK: objType := string(resp.Header.Peek(giteaObjectTypeHeader)) log.Trace().Msgf("server raw content object: %s", objType) if client.followSymlinks && objType == "symlink" { // TODO: limit to 1000 chars if we switched to std linkDest := strings.TrimSpace(string(resp.Body())) log.Debug().Msgf("follow symlink from '%s' to '%s'", resource, linkDest) return client.ServeRawContent(targetOwner, targetRepo, ref, linkDest) } return resp, nil case fasthttp.StatusNotFound: return nil, ErrorNotFound default: return nil, fmt.Errorf("unexpected status code '%d'", resp.StatusCode()) } } func (client *Client) GiteaGetRepoBranchTimestamp(repoOwner, repoName, branchName string) (time.Time, error) { url := joinURL(client.giteaRoot, giteaAPIRepos, repoOwner, repoName, "branches", branchName) res, err := client.do(client.infoTimeout, url) if err != nil { return time.Time{}, err } if res.StatusCode() != fasthttp.StatusOK { return time.Time{}, fmt.Errorf("unexpected status code '%d'", res.StatusCode()) } return time.Parse(time.RFC3339, fastjson.GetString(res.Body(), "commit", "timestamp")) } func (client *Client) GiteaGetRepoDefaultBranch(repoOwner, repoName string) (string, error) { url := joinURL(client.giteaRoot, giteaAPIRepos, repoOwner, repoName) res, err := client.do(client.infoTimeout, url) if err != nil { return "", err } if res.StatusCode() != fasthttp.StatusOK { return "", fmt.Errorf("unexpected status code '%d'", res.StatusCode()) } return fastjson.GetString(res.Body(), "default_branch"), nil } func (client *Client) do(timeout time.Duration, url string) (*fasthttp.Response, error) { req := fasthttp.AcquireRequest() req.SetRequestURI(url) req.Header.Set(fasthttp.HeaderAuthorization, "token "+client.giteaAPIToken) res := fasthttp.AcquireResponse() err := client.fastClient.DoTimeout(req, res, timeout) return res, err }