package backends import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strings" "sync" "time" ) const ( ghUserAgent = "quartermaster-backend-manager" ghTimeout = 30 * time.Second // releaseTTL keeps the version picker responsive without hammering the API: // unauthenticated GitHub allows 60 requests/hour per IP, and one modal open // can touch every component. releaseTTL = 10 * time.Minute // releasePage caps how far back the version picker can reach. llama.cpp cuts // several releases a day, so 30 is roughly a week of builds. releasePage = 30 ) // Asset is one downloadable file attached to a release. type Asset struct { Name string `json:"name"` URL string `json:"url"` Size int64 `json:"size"` } // Release is the subset of a GitHub release the manager needs. type Release struct { Tag string `json:"tag"` Name string `json:"name"` HTMLURL string `json:"htmlUrl"` PublishedAt time.Time `json:"publishedAt"` Prerelease bool `json:"prerelease"` Assets []Asset `json:"assets"` } // AssetNames lists the release's asset file names. func (r Release) AssetNames() []string { out := make([]string, 0, len(r.Assets)) for _, a := range r.Assets { out = append(out, a.Name) } return out } // AssetByName returns the named asset. func (r Release) AssetByName(name string) (Asset, bool) { for _, a := range r.Assets { if a.Name == name { return a, true } } return Asset{}, false } type ghRelease struct { TagName string `json:"tag_name"` Name string `json:"name"` HTMLURL string `json:"html_url"` PublishedAt time.Time `json:"published_at"` Prerelease bool `json:"prerelease"` Draft bool `json:"draft"` Assets []struct { Name string `json:"name"` URL string `json:"browser_download_url"` Size int64 `json:"size"` } `json:"assets"` } type cachedReleases struct { rel []Release at time.Time } // ghClient fetches (and briefly caches) release listings. type ghClient struct { http *http.Client mu sync.Mutex cache map[string]cachedReleases } func newGHClient() *ghClient { return &ghClient{ http: &http.Client{Timeout: ghTimeout}, cache: map[string]cachedReleases{}, } } // Releases lists a repo's recent releases, newest first. Results are cached for // releaseTTL unless force is set (the UI's explicit "check for updates"). func (g *ghClient) Releases(ctx context.Context, repo string, force bool) ([]Release, error) { // The repo reaches this function from a user-editable tracked source, and it // is interpolated into the API URL path below. Validate before it can move // the request off api.github.com/repos/. if err := ValidateRepo(repo); err != nil { return nil, err } g.mu.Lock() if c, ok := g.cache[repo]; ok && !force && time.Since(c.at) < releaseTTL { g.mu.Unlock() return c.rel, nil } g.mu.Unlock() api := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=%d", repo, releasePage) req, err := http.NewRequestWithContext(ctx, http.MethodGet, api, nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", ghUserAgent) if tok := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")); tok != "" { req.Header.Set("Authorization", "Bearer "+tok) } resp, err := g.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests { return nil, fmt.Errorf("github rate limit reached (%s) - retry later, or set GITHUB_TOKEN", resp.Status) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("github api %s: %s", repo, resp.Status) } var raw []ghRelease if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&raw); err != nil { return nil, err } out := make([]Release, 0, len(raw)) for _, r := range raw { if r.Draft { continue } rel := Release{ Tag: r.TagName, Name: r.Name, HTMLURL: r.HTMLURL, PublishedAt: r.PublishedAt, Prerelease: r.Prerelease, } for _, a := range r.Assets { rel.Assets = append(rel.Assets, Asset{Name: a.Name, URL: a.URL, Size: a.Size}) } out = append(out, rel) } g.mu.Lock() g.cache[repo] = cachedReleases{rel: out, at: time.Now()} g.mu.Unlock() return out, nil } // noReleaseError explains why nothing resolved. An empty listing used to be // reported as `no release "" found in the last 30 releases`, which reads like a // bad version request when the truth is that the repo has published nothing yet // (a freshly tracked fork, or one whose CI has not run). func noReleaseError(c Component, rels []Release, version string) error { if len(rels) == 0 { return fmt.Errorf("%s: %s has no releases yet", c.ID, c.Repo) } return fmt.Errorf("%s: no release %q found in the last %d releases", c.ID, version, releasePage) } // pickRelease resolves a requested version: "" or "latest" => the newest // non-prerelease that actually carries an asset for this variant/OS, else the // exact tag. // installable, when non-nil, reports whether a release carries an asset this // host can actually use; "latest" skips those it can't. Real-ESRGAN's newest // release is source-only, so without this "install latest" resolves to a // release with nothing to download. // allowPre lets "latest" pick a prerelease outright, for a tracked repo that // only ever publishes nightlies — without it such a source resolves through the // degraded fallback below and can silently pin an old build. func pickRelease(rels []Release, tag string, allowPre bool, installable func(Release) bool) (Release, bool) { usable := func(r Release) bool { return installable == nil || installable(r) } tag = strings.TrimSpace(tag) if tag != "" && !strings.EqualFold(tag, "latest") { for _, r := range rels { if r.Tag == tag { return r, true // an explicit tag is honoured as asked; the caller reports the mismatch } } return Release{}, false } for _, r := range rels { if (allowPre || !r.Prerelease) && usable(r) { return r, true } } // Nothing stable is installable: fall back to a prerelease that is, then to // the newest release at all so the caller's error names a real tag. for _, r := range rels { if usable(r) { return r, true } } if len(rels) > 0 { return rels[0], true } return Release{}, false } // validAssetURL only permits https downloads from GitHub-controlled hosts, so a // poisoned or MITM'd API response can't make us fetch and run an arbitrary // binary. Same guard as internal/update. func validAssetURL(raw string) error { u, err := url.Parse(raw) if err != nil { return err } host := strings.ToLower(u.Hostname()) ok := host == "github.com" || strings.HasSuffix(host, ".github.com") || strings.HasSuffix(host, ".githubusercontent.com") if u.Scheme != "https" || !ok { return fmt.Errorf("refusing non-GitHub download URL: %s", raw) } return nil }