512 lines
14 KiB
Go
512 lines
14 KiB
Go
package client
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"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"`
|
||
PackageType string `json:"package_type,omitempty"`
|
||
Channel string `json:"channel,omitempty"`
|
||
Status string `json:"status,omitempty"`
|
||
FileName string `json:"file_name,omitempty"`
|
||
Size int64 `json:"size"`
|
||
SHA256 string `json:"sha256"`
|
||
UploadedAt string `json:"uploaded_at"`
|
||
Targets []Target `json:"targets,omitempty"`
|
||
}
|
||
|
||
type Target struct {
|
||
ID string `json:"id"`
|
||
Platform string `json:"platform"`
|
||
Architecture string `json:"architecture"`
|
||
PackageType string `json:"package_type"`
|
||
FileName string `json:"file_name"`
|
||
Size int64 `json:"size"`
|
||
SHA256 string `json:"sha256"`
|
||
ArtifactID string `json:"artifact_id"`
|
||
Entrypoint string `json:"entrypoint,omitempty"`
|
||
UpgradeFrom []string `json:"upgrade_from,omitempty"`
|
||
}
|
||
|
||
type Update struct {
|
||
Package Package `json:"package"`
|
||
}
|
||
|
||
type UploadRequest struct {
|
||
Name string
|
||
Version string
|
||
Description string
|
||
Platform string
|
||
Architecture string
|
||
PackageType string
|
||
Channel string
|
||
Status string
|
||
UpgradeFrom []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,
|
||
"package_type": input.PackageType,
|
||
"channel": input.Channel,
|
||
"status": input.Status,
|
||
"upgrade_from": strings.Join(input.UpgradeFrom, ","),
|
||
}
|
||
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
|
||
}
|
||
|
||
type User struct {
|
||
ID string `json:"id"`
|
||
Email string `json:"email"`
|
||
Name string `json:"name"`
|
||
Role string `json:"role"`
|
||
Active bool `json:"active"`
|
||
CreatedAt string `json:"created_at"`
|
||
UpdatedAt string `json:"updated_at"`
|
||
}
|
||
|
||
type LoginResult struct {
|
||
User User `json:"user"`
|
||
Session string `json:"session"`
|
||
ExpiresAt string `json:"expires_at"`
|
||
}
|
||
|
||
func WithSessionToken(token string) Option {
|
||
return func(client *Client) {
|
||
client.token = token
|
||
}
|
||
}
|
||
|
||
func (c *Client) Register(ctx context.Context, email, name, password string) (User, error) {
|
||
var result struct {
|
||
User User `json:"user"`
|
||
}
|
||
if err := c.postJSON(ctx, "/api/v1/auth/register", map[string]string{"email": email, "name": name, "password": password}, &result); err != nil {
|
||
return User{}, err
|
||
}
|
||
return result.User, nil
|
||
}
|
||
|
||
func (c *Client) Login(ctx context.Context, email, password string) (LoginResult, error) {
|
||
var result LoginResult
|
||
if err := c.postJSON(ctx, "/api/v1/auth/login", map[string]string{"email": email, "password": password}, &result); err != nil {
|
||
return LoginResult{}, err
|
||
}
|
||
c.token = result.Session
|
||
return result, nil
|
||
}
|
||
|
||
func (c *Client) Me(ctx context.Context) (User, error) {
|
||
var result struct {
|
||
User User `json:"user"`
|
||
}
|
||
if err := c.getJSON(ctx, "/api/v1/auth/me", &result); err != nil {
|
||
return User{}, err
|
||
}
|
||
return result.User, nil
|
||
}
|
||
|
||
func (c *Client) Logout(ctx context.Context) error {
|
||
var result map[string]string
|
||
if err := c.postJSON(ctx, "/api/v1/auth/logout", map[string]string{}, &result); err != nil {
|
||
return err
|
||
}
|
||
c.token = ""
|
||
return nil
|
||
}
|
||
|
||
func (c *Client) RequestPasswordReset(ctx context.Context, email string) error {
|
||
var result map[string]string
|
||
return c.postJSON(ctx, "/api/v1/auth/password/request", map[string]string{"email": email}, &result)
|
||
}
|
||
|
||
func (c *Client) ResetPassword(ctx context.Context, token, newPassword string) error {
|
||
var result map[string]string
|
||
return c.postJSON(ctx, "/api/v1/auth/password/reset", map[string]string{"token": token, "new_password": newPassword}, &result)
|
||
}
|
||
|
||
func (c *Client) postJSON(ctx context.Context, path string, input any, target any) error {
|
||
body, err := json.Marshal(input)
|
||
if err != nil {
|
||
return fmt.Errorf("编码请求失败: %w", err)
|
||
}
|
||
request, err := c.newRequest(ctx, http.MethodPost, path, bytes.NewReader(body))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
request.Header.Set("Content-Type", "application/json")
|
||
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 target == nil {
|
||
return nil
|
||
}
|
||
if err := json.NewDecoder(response.Body).Decode(target); err != nil {
|
||
return fmt.Errorf("解析服务端响应失败: %w", err)
|
||
}
|
||
return nil
|
||
}
|