Scout/pkg/client/client.go
2026-08-30 22:11:40 +08:00

388 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package client
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
)
type Package struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
Platform string `json:"platform,omitempty"`
Architecture string `json:"architecture,omitempty"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
UploadedAt string `json:"uploaded_at"`
}
type Update struct {
Package Package `json:"package"`
}
type UploadRequest struct {
Name string
Version string
Description string
Platform string
Architecture string
FileName string
File io.Reader
}
type Option func(*Client)
type Client struct {
baseURL string
httpClient *http.Client
token string
}
func New(baseURL string, options ...Option) (*Client, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return nil, errors.New("服务端地址不能为空")
}
if _, err := url.ParseRequestURI(baseURL); err != nil {
return nil, fmt.Errorf("服务端地址无效: %w", err)
}
client := &Client{baseURL: baseURL, httpClient: http.DefaultClient}
for _, option := range options {
option(client)
}
return client, nil
}
func WithHTTPClient(httpClient *http.Client) Option {
return func(client *Client) {
if httpClient != nil {
client.httpClient = httpClient
}
}
}
func WithDeveloperToken(token string) Option {
return func(client *Client) {
client.token = token
}
}
func (c *Client) ListPackages(ctx context.Context) ([]Package, error) {
var response struct {
Packages []Package `json:"packages"`
}
if err := c.getJSON(ctx, "/api/v1/packages", &response); err != nil {
return nil, err
}
return response.Packages, nil
}
func (c *Client) GetLatest(ctx context.Context, name string) (Package, error) {
var item Package
if err := c.getJSON(ctx, "/api/v1/packages/"+url.PathEscape(name), &item); err != nil {
return Package{}, err
}
return item, nil
}
func (c *Client) ListVersions(ctx context.Context, name string) ([]Package, error) {
var response struct {
Name string `json:"name"`
Versions []Package `json:"versions"`
}
if err := c.getJSON(ctx, "/api/v1/packages/"+url.PathEscape(name)+"/versions", &response); err != nil {
return nil, err
}
return response.Versions, nil
}
func (c *Client) CheckUpdate(ctx context.Context, name, currentVersion string) (Package, bool, error) {
latest, err := c.GetLatest(ctx, name)
if err != nil {
return Package{}, false, err
}
return latest, CompareVersions(latest.Version, currentVersion) > 0, nil
}
func (c *Client) Download(ctx context.Context, name, version, destination string) (Package, error) {
if version == "" {
latest, err := c.GetLatest(ctx, name)
if err != nil {
return Package{}, err
}
version = latest.Version
}
path := "/api/v1/packages/" + url.PathEscape(name) + "/" + url.PathEscape(version) + "/download"
request, err := c.newRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return Package{}, err
}
response, err := c.httpClient.Do(request)
if err != nil {
return Package{}, fmt.Errorf("请求下载失败: %w", err)
}
defer response.Body.Close()
if err := checkResponse(response); err != nil {
return Package{}, err
}
if destination == "" {
return Package{}, errors.New("保存路径不能为空")
}
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
return Package{}, fmt.Errorf("创建保存目录失败: %w", err)
}
file, err := os.Create(destination)
if err != nil {
return Package{}, fmt.Errorf("创建保存文件失败: %w", err)
}
hash := sha256.New()
_, copyErr := io.Copy(io.MultiWriter(file, hash), response.Body)
closeErr := file.Close()
if copyErr != nil {
return Package{}, fmt.Errorf("保存包文件失败: %w", copyErr)
}
if closeErr != nil {
return Package{}, fmt.Errorf("关闭保存文件失败: %w", closeErr)
}
digest := hex.EncodeToString(hash.Sum(nil))
expected := response.Header.Get("X-Scout-SHA256")
if expected != "" && !strings.EqualFold(expected, digest) {
return Package{}, fmt.Errorf("包文件校验失败: 期望 %s,实际 %s", expected, digest)
}
return Package{
Name: name,
Version: version,
Size: response.ContentLength,
SHA256: digest,
}, nil
}
func (c *Client) Upload(ctx context.Context, input UploadRequest) (Package, error) {
if c.token == "" {
return Package{}, errors.New("上传包需要开发者令牌")
}
if input.File == nil {
return Package{}, errors.New("包文件不能为空")
}
bodyReader, bodyWriter := io.Pipe()
multipartWriter := multipart.NewWriter(bodyWriter)
go func() {
err := writeUploadForm(multipartWriter, input)
if closeErr := multipartWriter.Close(); err == nil {
err = closeErr
}
_ = bodyWriter.CloseWithError(err)
}()
request, err := c.newRequest(ctx, http.MethodPost, "/api/v1/packages/upload", bodyReader)
if err != nil {
return Package{}, err
}
request.Header.Set("Content-Type", multipartWriter.FormDataContentType())
request.Header.Set("Authorization", "Bearer "+c.token)
response, err := c.httpClient.Do(request)
if err != nil {
return Package{}, fmt.Errorf("请求上传失败: %w", err)
}
defer response.Body.Close()
if err := checkResponse(response); err != nil {
return Package{}, err
}
var item Package
if err := json.NewDecoder(response.Body).Decode(&item); err != nil {
return Package{}, fmt.Errorf("解析上传响应失败: %w", err)
}
return item, nil
}
func (c *Client) WatchUpdates(ctx context.Context, packages ...string) (<-chan Update, error) {
query := url.Values{}
wanted := make([]string, 0, len(packages))
for _, name := range packages {
if strings.TrimSpace(name) != "" {
wanted = append(wanted, name)
}
}
if len(wanted) > 0 {
query.Set("packages", strings.Join(wanted, ","))
}
path := "/api/v1/updates/stream"
if encoded := query.Encode(); encoded != "" {
path += "?" + encoded
}
request, err := c.newRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
response, err := c.httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("连接更新通知失败: %w", err)
}
if err := checkResponse(response); err != nil {
response.Body.Close()
return nil, err
}
updates := make(chan Update)
go readUpdates(ctx, response.Body, updates)
return updates, nil
}
func (c *Client) getJSON(ctx context.Context, path string, target any) error {
request, err := c.newRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return err
}
response, err := c.httpClient.Do(request)
if err != nil {
return fmt.Errorf("请求失败: %w", err)
}
defer response.Body.Close()
if err := checkResponse(response); err != nil {
return err
}
if err := json.NewDecoder(response.Body).Decode(target); err != nil {
return fmt.Errorf("解析服务端响应失败: %w", err)
}
return nil
}
func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
if c.token != "" {
request.Header.Set("Authorization", "Bearer "+c.token)
}
return request, nil
}
func writeUploadForm(writer *multipart.Writer, input UploadRequest) error {
fields := map[string]string{
"name": input.Name,
"version": input.Version,
"description": input.Description,
"platform": input.Platform,
"architecture": input.Architecture,
}
for name, value := range fields {
if err := writer.WriteField(name, value); err != nil {
return err
}
}
part, err := writer.CreateFormFile("file", input.FileName)
if err != nil {
return err
}
_, err = io.Copy(part, input.File)
return err
}
func checkResponse(response *http.Response) error {
if response.StatusCode >= 200 && response.StatusCode < 300 {
return nil
}
body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20))
message := strings.TrimSpace(string(body))
if message == "" {
message = response.Status
}
return fmt.Errorf("服务端返回 %s: %s", response.Status, message)
}
func readUpdates(ctx context.Context, body io.ReadCloser, updates chan<- Update) {
defer close(updates)
defer body.Close()
stopCloser := make(chan struct{})
defer close(stopCloser)
go func() {
select {
case <-ctx.Done():
_ = body.Close()
case <-stopCloser:
}
}()
scanner := bufio.NewScanner(body)
scanner.Buffer(make([]byte, 4096), 4<<20)
dataLines := make([]string, 0, 1)
for scanner.Scan() {
line := strings.TrimSuffix(scanner.Text(), "\r")
if line == "" {
if len(dataLines) > 0 {
var update Update
if json.Unmarshal([]byte(strings.Join(dataLines, "\n")), &update) == nil {
select {
case updates <- update:
case <-ctx.Done():
return
}
}
}
dataLines = dataLines[:0]
continue
}
if strings.HasPrefix(line, "data:") {
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
}
func CompareVersions(left, right string) int {
leftParts := splitVersion(left)
rightParts := splitVersion(right)
for index := 0; index < len(leftParts) || index < len(rightParts); index++ {
if index >= len(leftParts) {
return -1
}
if index >= len(rightParts) {
return 1
}
leftNumber, leftOK := numberPart(leftParts[index])
rightNumber, rightOK := numberPart(rightParts[index])
if leftOK && rightOK && leftNumber != rightNumber {
if leftNumber > rightNumber {
return 1
}
return -1
}
if leftParts[index] != rightParts[index] {
return strings.Compare(leftParts[index], rightParts[index])
}
}
return 0
}
func splitVersion(version string) []string {
version = strings.TrimPrefix(version, "v")
version = strings.SplitN(version, "+", 2)[0]
parts := strings.FieldsFunc(version, func(char rune) bool {
return char == '.' || char == '-' || char == '_'
})
return parts
}
func numberPart(value string) (int64, bool) {
if value == "" {
return 0, false
}
var number int64
for _, char := range value {
if char < '0' || char > '9' {
return 0, false
}
number = number*10 + int64(char-'0')
}
return number, true
}