// Package gitea is a minimal read-only client for the Gitea generic-package API. It lets the hub // discover which artifact versions currently exist (olders get pruned) and read each file's sha256 // WITHOUT downloading the artifact — so the operator picks a version in the UI and the hub derives and // vouches the checksum itself, instead of the operator pasting a 64-hex sha by hand. package gitea import ( "context" "encoding/json" "fmt" "net/http" "sort" "strconv" "strings" "time" ) // Client talks to the Gitea packages API with basic auth (the registry username + token the hub // already holds for the controller image pull). type Client struct { baseURL string // e.g. https://gitea.dooplex.hu (no trailing slash) owner string // package owner, e.g. admin user string token string http *http.Client } // New builds a client. baseURL/owner/user/token come from the hub's registry config. func New(baseURL, owner, user, token string) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), owner: owner, user: user, token: token, http: &http.Client{Timeout: 10 * time.Second}, } } type pkgEntry struct { Name string `json:"name"` Version string `json:"version"` Type string `json:"type"` } // ListVersions returns the versions of a generic package, newest-semver first. Non-semver versions // (if any) sort after the semver ones so nothing is silently dropped. func (c *Client) ListVersions(ctx context.Context, pkgName string) ([]string, error) { u := fmt.Sprintf("%s/api/v1/packages/%s?type=generic&q=%s&limit=100", c.baseURL, c.owner, pkgName) var pkgs []pkgEntry if err := c.getJSON(ctx, u, &pkgs); err != nil { return nil, err } var vers []string for _, p := range pkgs { if p.Name == pkgName && p.Version != "" { vers = append(vers, p.Version) } } sort.SliceStable(vers, func(i, j int) bool { return compareSemver(vers[i], vers[j]) > 0 }) return vers, nil } type pkgFile struct { Name string `json:"name"` SHA256 string `json:"sha256"` } // FileSHA256 returns the sha256 of a file inside a generic package version. When the version holds // multiple files it prefers an exact match to preferredFile (e.g. "golden.tar.zst"); otherwise it uses // the first file. This is a cheap metadata call — the artifact bytes are never downloaded. func (c *Client) FileSHA256(ctx context.Context, pkgName, version, preferredFile string) (string, error) { u := fmt.Sprintf("%s/api/v1/packages/%s/generic/%s/%s/files", c.baseURL, c.owner, pkgName, version) var files []pkgFile if err := c.getJSON(ctx, u, &files); err != nil { return "", err } if len(files) == 0 { return "", fmt.Errorf("no files listed for %s/%s", pkgName, version) } if preferredFile != "" { for _, f := range files { if f.Name == preferredFile && f.SHA256 != "" { return f.SHA256, nil } } } if files[0].SHA256 == "" { return "", fmt.Errorf("no sha256 for %s/%s file %q", pkgName, version, files[0].Name) } return files[0].SHA256, nil } func (c *Client) getJSON(ctx context.Context, url string, out interface{}) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } req.SetBasicAuth(c.user, c.token) req.Header.Set("Accept", "application/json") resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("gitea GET %s: HTTP %d", url, resp.StatusCode) } return json.NewDecoder(resp.Body).Decode(out) } // compareSemver returns >0 if a>b, 0 if equal, <0 if a