Files
router-for-me--cliproxyapi/internal/pluginstore/github.go
T
2026-07-13 12:35:43 +08:00

297 lines
8.8 KiB
Go

package pluginstore
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/httpfetch"
log "github.com/sirupsen/logrus"
)
const userAgent = "CLIProxyAPI"
const maxPluginStoreRedirects = 10
// HTTPDoer abstracts the HTTP client used to execute requests.
type HTTPDoer = httpfetch.Doer
type Client struct {
HTTPClient HTTPDoer
RegistryURL string
UserAgent string
Auth []AuthConfig
}
type Release struct {
TagName string `json:"tag_name"`
Assets []ReleaseAsset `json:"assets"`
}
type ReleaseAsset struct {
APIURL string `json:"url"`
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", RequestKindRegistry, 0)
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", RequestKindMetadata, 0)
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
}
// FetchReleaseByTag returns a published release by its exact GitHub tag.
func (c Client) FetchReleaseByTag(ctx context.Context, plugin Plugin, tag string) (Release, error) {
owner, repo, errRepository := GitHubRepositoryParts(plugin.Repository)
if errRepository != nil {
return Release{}, errRepository
}
tag = strings.TrimSpace(tag)
if tag == "" {
return Release{}, fmt.Errorf("release tag is required")
}
releaseURL := fmt.Sprintf(
"https://api.github.com/repos/%s/%s/releases/tags/%s",
url.PathEscape(owner),
url.PathEscape(repo),
url.PathEscape(tag),
)
data, errDownload := c.get(ctx, releaseURL, "application/vnd.github+json", RequestKindMetadata, 0)
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) {
downloadURL := strings.TrimSpace(asset.BrowserDownloadURL)
apiURL := strings.TrimSpace(asset.APIURL)
if downloadURL == "" || c.releaseAssetAPIAuthenticated(apiURL) {
if apiURL != "" {
downloadURL = apiURL
}
}
if downloadURL == "" {
return nil, fmt.Errorf("asset %q missing download url", asset.Name)
}
return c.get(ctx, downloadURL, "application/octet-stream", RequestKindArtifact, 0)
}
func (c Client) releaseAssetAPIAuthenticated(apiURL string) bool {
apiURL = strings.TrimSpace(apiURL)
if apiURL == "" {
return false
}
return AuthConfigured(c.Auth, apiURL, RequestKindArtifact)
}
func (c Client) get(ctx context.Context, requestURL string, accept string, kind string, maxSize int64) ([]byte, error) {
currentURL := strings.TrimSpace(requestURL)
for redirects := 0; ; redirects++ {
if errURL := validatePluginStoreRequestURL(c.Auth, currentURL, kind); errURL != nil {
return nil, errURL
}
headers := http.Header{
"Accept": []string{accept},
"User-Agent": []string{c.userAgent()},
}
if errAuth := applyPluginStoreAuth(headers, c.Auth, currentURL, kind); errAuth != nil {
return nil, errAuth
}
resp, errDo := pluginStoreGetNoRedirect(ctx, c.httpClient(), currentURL, headers)
if errDo != nil {
return nil, errDo
}
if pluginStoreRedirectStatus(resp.StatusCode) {
nextURL, errRedirect := pluginStoreRedirectURL(resp, currentURL)
if errClose := resp.Body.Close(); errClose != nil {
log.WithError(errClose).Debug("failed to close plugin store redirect body")
}
if errRedirect != nil {
return nil, errRedirect
}
if redirects >= maxPluginStoreRedirects {
return nil, fmt.Errorf("stopped after %d redirects", maxPluginStoreRedirects)
}
currentURL = nextURL
continue
}
return readPluginStoreResponse(resp, maxSize)
}
}
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 pluginStoreGetNoRedirect(ctx context.Context, client HTTPDoer, requestURL string, headers http.Header) (*http.Response, error) {
if client == nil {
client = http.DefaultClient
}
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if errRequest != nil {
return nil, fmt.Errorf("create request: %w", errRequest)
}
req.Header = headers.Clone()
resp, errDo := pluginStoreNoRedirectClient(client).Do(req)
if errDo != nil {
return nil, fmt.Errorf("request failed: %w", errDo)
}
return resp, nil
}
func pluginStoreNoRedirectClient(client HTTPDoer) HTTPDoer {
httpClient, ok := client.(*http.Client)
if !ok {
return client
}
clone := *httpClient
clone.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
return &clone
}
func pluginStoreRedirectStatus(status int) bool {
switch status {
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
return true
default:
return false
}
}
func pluginStoreRedirectURL(resp *http.Response, requestURL string) (string, error) {
location := strings.TrimSpace(resp.Header.Get("Location"))
if location == "" {
return "", fmt.Errorf("redirect missing Location header")
}
base, errBase := url.Parse(requestURL)
if errBase != nil {
return "", fmt.Errorf("parse redirect base: %w", errBase)
}
next, errNext := base.Parse(location)
if errNext != nil {
return "", fmt.Errorf("parse redirect location: %w", errNext)
}
if next.Scheme == "" || next.Host == "" {
return "", fmt.Errorf("redirect location is not absolute")
}
return next.String(), nil
}
func readPluginStoreResponse(resp *http.Response, maxSize int64) ([]byte, error) {
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.WithError(errClose).Debug("failed to close plugin store response body")
}
}()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
reader := io.Reader(resp.Body)
if maxSize > 0 {
reader = io.LimitReader(resp.Body, maxSize+1)
}
data, errRead := io.ReadAll(reader)
if errRead != nil {
return nil, fmt.Errorf("read response: %w", errRead)
}
if maxSize > 0 && int64(len(data)) > maxSize {
return nil, fmt.Errorf("response exceeds maximum allowed size of %d bytes", maxSize)
}
return data, nil
}
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),
)
}