Files
magpie/internal/library/github.go
T
yetone 4afb698638 library: checking skills for updates asks GitHub with a GitHub token, set in Settings → Network and sharing or taken from GITHUB_TOKEN / GH_TOKEN, and a check GitHub limited says until when and that a token raises it (Player on Discord: 资源库检查技能更新时提示 github is limiting request,希望能设置 GitHub token) — the check asks GitHub's API for the last commit to each skill's folder, which GitHub answers 60 times an hour for an address without a token, so a library of a few repositories, checked twice, or a network shared with others, used it up, and the check said only "GitHub is limiting requests from here until 15:04", with nothing to do about it. Now the API's requests carry Authorization: Bearer <token> (5,000 an hour) when there is one: the GitHub token set in Settings → Network and sharing (a new GitHub row: typed masked, saved on its own with POST /api/settings/github-token, then shown as ghp_…0123 with Remove; one with a space is refused in the row, what was typed kept), else GITHUB_TOKEN, else GH_TOKEN, the row saying when one of those is used. gh auth token is not run: it would borrow another app's credential without asking. The token goes to GitHub's API's host alone (withGitHubToken checks the request's host against githubAPI's), never to codeload, raw.githubusercontent or anything else, and Go's client drops it on a redirect to another host; rtk's latest-release lookup on api.github.com carries it too. It is kept in settings.json as the LAN key is: the page is told only the masked one and where it is from (githubTokenMask, githubTokenFrom), a save of the Settings page keeps it, a backup or sync without keys leaves it out, and restoring one without keys keeps this machine's. Each folder's answer is kept with its ETag and asked for again with If-None-Match, so an unchanged one is a 304, which GitHub doesn't count against the limit. A rate limit (403 with the limit spent, a secondary limit's Retry-After, or 429) now says it plainly: without a token, "GitHub's rate limit for requests without a token (60 an hour) is used up until 15:04. Add a GitHub token in Settings → Network and sharing to raise it to 5,000 an hour"; with one, that the token's limit is used up until then; and the check carries limited {until, token} for the page, which says the same in its own words, in English and Chinese, in the status and on each Not checked tag. A token GitHub refuses (401) says which: the one in Settings, or GITHUB_TOKEN / GH_TOKEN. The library's test sandbox clears GITHUB_TOKEN and GH_TOKEN, so no developer's token reaches a fake GitHub. TestGitHubTokenSent (no token: none sent; GH_TOKEN; GITHUB_TOKEN before it; Settings before both; codeload never gets it), TestGitHubTokenOnlyToTheAPI (api.github.com only, not codeload, raw, a look-alike host or the gateway; dropped on a redirect), TestGitHubRateLimitSaid (until when and the token advice without one, not with one, the token never in the error, a secondary limit, a refused token from Settings and from GH_TOKEN), TestGitHubConditionalRequests (If-None-Match, and the 304 answered as the 200 was), TestGitHubTokenBackup (left out without keys, fails without the change; kept on a keyless restore), TestGitHubTokenSetting (set, masked, kept by a Settings save, refused with a space, removed with the environment's then said) and github-token.test.cjs (WebKit, en and zh: the row, a refusal keeping what was typed, saved masked with the token nowhere on the page, removed with GH_TOKEN said, nothing scrolls; a limited check's status and tag say until when, and the token advice only without one) are new; TestCheckSkills, TestSettingsSaveKeepsWhatItDoesNotSend (now with the token) pass. GitHub was never called: httptest stands in for it. settings-sections, search-api, click-scroll, library-toggle-status, library-all, otel, lan-docker, every-skill pass in WebKit. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestFXInState failed on its TempDir cleanup, the known flake).
2026-10-02 21:16:50 +08:00

113 lines
3.5 KiB
Go

package library
import (
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/settings"
)
// ---- asking GitHub's API with the user's token ----------------------------
//
// GitHub's REST API answers an address 60 requests an hour without a token,
// which a check of a few repositories' skills, or a network shared with
// others, uses up; with a token it answers 5,000 an hour. The token is the
// one set in Settings (GitHubToken), else GITHUB_TOKEN or GH_TOKEN from the
// environment. It goes to GitHub's API alone (githubAPI's host), never to
// codeload or anywhere else, and is never written to a log or an error.
// GitHubTokenEnv names the environment variables a token is taken from when
// Settings has none, in the order they are looked at.
var GitHubTokenEnv = []string{"GITHUB_TOKEN", "GH_TOKEN"}
// GitHubToken is the token requests to GitHub's API carry, and where it
// came from: "settings", the environment variable's name, or "" for none.
func GitHubToken() (token, from string) {
if t := strings.TrimSpace(settings.Load().GitHubToken); t != "" {
return t, "settings"
}
for _, k := range GitHubTokenEnv {
if t := strings.TrimSpace(os.Getenv(k)); t != "" {
return t, k
}
}
return "", ""
}
// githubAPIHost is the host a token may be sent to: GitHub's API's.
func githubAPIHost() string {
u, err := url.Parse(githubAPI)
if err != nil {
return ""
}
return u.Host
}
// withGitHubToken has req carry the token, if there is one and req goes to
// GitHub's API; it says whether it did.
func withGitHubToken(req *http.Request) bool {
if req.URL == nil || req.URL.Host == "" || !strings.EqualFold(req.URL.Host, githubAPIHost()) {
return false
}
t, _ := GitHubToken()
if t == "" {
return false
}
req.Header.Set("Authorization", "Bearer "+t)
return true
}
// errLimited is GitHub turning requests away for a while: the API allows
// an address 60 an hour without a token, 5,000 with one.
type errLimited struct {
until time.Time
token bool // the requests carried a token
}
func (e errLimited) Error() string {
when := "for a while"
if !e.until.IsZero() {
when = "until " + e.until.Local().Format("15:04")
}
if e.token {
return "GitHub's rate limit for your GitHub token is used up " + when + "; try again then"
}
return "GitHub's rate limit for requests without a token (60 an hour) is used up " + when +
". Add a GitHub token in Settings → Network and sharing to raise it to 5,000 an hour"
}
// limitedBy is the errLimited a refusal of GitHub's says, or false when it
// isn't one: 429, or 403 with the limit spent or a secondary limit's
// Retry-After or message.
func limitedBy(resp *http.Response, body []byte, token bool) (errLimited, bool) {
msg := strings.ToLower(string(body))
if resp.StatusCode != 429 && (resp.StatusCode != 403 || resp.Header.Get("X-RateLimit-Remaining") != "0" &&
resp.Header.Get("Retry-After") == "" && !strings.Contains(msg, "rate limit")) {
return errLimited{}, false
}
e := errLimited{token: token}
if n, err := strconv.ParseInt(resp.Header.Get("X-RateLimit-Reset"), 10, 64); err == nil && n > 0 {
e.until = time.Unix(n, 0)
} else if s, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && s > 0 {
e.until = time.Now().Add(time.Duration(s) * time.Second)
}
return e, true
}
// etags are GitHub's answers kept by URL, for the next request for one to
// ask if it changed: a 304 doesn't count against the rate limit.
var etags = struct {
sync.Mutex
m map[string]etagged
}{m: map[string]etagged{}}
type etagged struct {
etag string
body []byte
}