chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
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),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user