Files
CLIProxyAPI/internal/pluginstore/github.go
LTbinglingfeng 40f4b8b856 feat(pluginstore): fetch and install plugins from latest release
Replace the tag-pinned release lookup with the repository latest
release endpoint. Derive the plugin version from the release tag,
validate it, and attach an optional token to API requests to raise
the rate limit.
2026-06-13 04:00:05 +08:00

161 lines
4.6 KiB
Go

package pluginstore
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/httpfetch"
)
const userAgent = "CLIProxyAPI"
// HTTPDoer abstracts the HTTP client used to execute requests.
type HTTPDoer = httpfetch.Doer
type Client struct {
HTTPClient HTTPDoer
RegistryURL string
UserAgent string
}
type Release struct {
TagName string `json:"tag_name"`
Assets []ReleaseAsset `json:"assets"`
}
type ReleaseAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
func (c Client) FetchRegistry(ctx context.Context) (Registry, error) {
registryURL := strings.TrimSpace(c.RegistryURL)
if registryURL == "" {
registryURL = DefaultRegistryURL
}
data, errDownload := c.get(ctx, registryURL, "application/json")
if errDownload != nil {
return Registry{}, errDownload
}
registry, errParse := ParseRegistry(data)
if errParse != nil {
return Registry{}, errParse
}
return registry, nil
}
// FetchLatestRelease returns the latest published release of the plugin's
// GitHub repository, mirroring the WebUI panel update check.
func (c Client) FetchLatestRelease(ctx context.Context, plugin Plugin) (Release, error) {
owner, repo, errRepository := GitHubRepositoryParts(plugin.Repository)
if errRepository != nil {
return Release{}, errRepository
}
releaseURL := fmt.Sprintf(
"https://api.github.com/repos/%s/%s/releases/latest",
url.PathEscape(owner),
url.PathEscape(repo),
)
data, errDownload := c.get(ctx, releaseURL, "application/vnd.github+json")
if errDownload != nil {
return Release{}, errDownload
}
var release Release
if errDecode := json.Unmarshal(data, &release); errDecode != nil {
return Release{}, fmt.Errorf("decode release: %w", errDecode)
}
return release, nil
}
// ReleaseVersion derives the plugin version from the release tag, stripping a
// leading "v"/"V" and validating the result.
func ReleaseVersion(release Release) (string, error) {
version := normalizeVersion(release.TagName)
if !validPluginVersion(version) {
return "", fmt.Errorf("invalid release tag %q", release.TagName)
}
return version, nil
}
func (c Client) DownloadAsset(ctx context.Context, asset ReleaseAsset) ([]byte, error) {
if strings.TrimSpace(asset.BrowserDownloadURL) == "" {
return nil, fmt.Errorf("asset %q missing browser_download_url", asset.Name)
}
return c.get(ctx, asset.BrowserDownloadURL, "application/octet-stream")
}
func (c Client) get(ctx context.Context, requestURL string, accept string) ([]byte, error) {
headers := map[string]string{
"Accept": accept,
"User-Agent": c.userAgent(),
}
if token := gitHubAPIToken(requestURL); token != "" {
headers["Authorization"] = "Bearer " + token
}
return httpfetch.GetBytes(ctx, c.httpClient(), requestURL, headers, 0)
}
// gitHubAPIToken returns the optional GitHub token for GitHub API requests to
// raise the unauthenticated rate limit, mirroring the management asset updater.
func gitHubAPIToken(requestURL string) string {
parsed, errParse := url.Parse(requestURL)
if errParse != nil || !strings.EqualFold(parsed.Host, "api.github.com") {
return ""
}
gitURL := strings.ToLower(strings.TrimSpace(os.Getenv("GITSTORE_GIT_URL")))
if !strings.Contains(gitURL, "github.com") {
return ""
}
return strings.TrimSpace(os.Getenv("GITSTORE_GIT_TOKEN"))
}
func (c Client) httpClient() HTTPDoer {
if c.HTTPClient != nil {
return c.HTTPClient
}
return http.DefaultClient
}
func (c Client) userAgent() string {
if strings.TrimSpace(c.UserAgent) != "" {
return strings.TrimSpace(c.UserAgent)
}
return userAgent
}
func SelectReleaseAssets(release Release, id, version, goos, goarch string) (ReleaseAsset, ReleaseAsset, error) {
archiveName := ArchiveName(id, version, goos, goarch)
var archiveAsset ReleaseAsset
var checksumAsset ReleaseAsset
for _, asset := range release.Assets {
switch strings.TrimSpace(asset.Name) {
case archiveName:
archiveAsset = asset
case "checksums.txt":
checksumAsset = asset
}
}
if strings.TrimSpace(archiveAsset.Name) == "" {
return ReleaseAsset{}, ReleaseAsset{}, fmt.Errorf("release asset %s not found", archiveName)
}
if strings.TrimSpace(checksumAsset.Name) == "" {
return ReleaseAsset{}, ReleaseAsset{}, fmt.Errorf("release asset checksums.txt not found")
}
return archiveAsset, checksumAsset, nil
}
func ArchiveName(id, version, goos, goarch string) string {
return fmt.Sprintf(
"%s_%s_%s_%s.zip",
strings.TrimSpace(id),
strings.TrimSpace(version),
strings.TrimSpace(goos),
strings.TrimSpace(goarch),
)
}