Scout/pkg/client/client.go

621 lines
17 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"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
)
type Package struct {
Project string `json:"project,omitempty"`
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
project 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)
}
if client.project != "" {
if err := validateProjectID(client.project); err != nil {
return nil, err
}
}
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 WithProject(project string) Option {
return func(client *Client) {
client.project = strings.TrimSpace(project)
}
}
func NewProject(baseURL, project string, options ...Option) (*Client, error) {
project = strings.TrimSpace(project)
if err := validateProjectID(project); err != nil {
return nil, err
}
options = append(options, WithProject(project))
return New(baseURL, options...)
}
func validateProjectID(project string) error {
project = strings.TrimSpace(project)
if project == "" {
return errors.New("\u9879\u76ee\u6807\u8bc6\u4e0d\u80fd\u4e3a\u7a7a")
}
if len(project) > 64 {
return errors.New("\u9879\u76ee\u6807\u8bc6\u957f\u5ea6\u4e0d\u80fd\u8d85\u8fc764\u4e2a\u5b57\u7b26")
}
for index, char := range project {
if index == 0 && (char == '.' || char == '-' || char == '_') {
return errors.New("\u9879\u76ee\u6807\u8bc6\u5f00\u5934\u5fc5\u987b\u662f\u5b57\u6bcd\u6216\u6570\u5b57")
}
if !(char == '-' || char == '_' || char == '.' || char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9') {
return errors.New("\u9879\u76ee\u6807\u8bc6\u5305\u542b\u65e0\u6548\u5b57\u7b26")
}
}
return nil
}
func (c *Client) Project() string {
return c.project
}
func (c *Client) resourcePath(path string) string {
if c.project == "" {
return path
}
const apiPrefix = "/api/v1"
if !strings.HasPrefix(path, apiPrefix) {
return path
}
return "/api/v1/projects/" + url.PathEscape(c.project) + path[len(apiPrefix):]
}
func (c *Client) ListPackages(ctx context.Context) ([]Package, error) {
var response struct {
Packages []Package `json:"packages"`
}
if err := c.getJSON(ctx, c.resourcePath("/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, c.resourcePath("/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, c.resourcePath("/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
}
if strings.TrimSpace(destination) == "" {
return Package{}, errors.New("保存路径不能为空")
}
path := c.resourcePath("/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 err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
return Package{}, fmt.Errorf("创建保存目录失败: %w", err)
}
temporary, err := os.CreateTemp(filepath.Dir(destination), ".scout-download-*")
if err != nil {
return Package{}, fmt.Errorf("创建临时下载文件失败: %w", err)
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
hash := sha256.New()
size, copyErr := io.Copy(io.MultiWriter(temporary, hash), response.Body)
closeErr := temporary.Close()
if copyErr != nil {
return Package{}, fmt.Errorf("保存包文件失败: %w", copyErr)
}
if closeErr != nil {
return Package{}, fmt.Errorf("关闭临时下载文件失败: %w", closeErr)
}
if response.ContentLength >= 0 && size != response.ContentLength {
return Package{}, fmt.Errorf("包文件大小校验失败: 期望 %d,实际 %d", response.ContentLength, size)
}
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)
}
if err := copyDownloadedFile(temporaryName, destination); err != nil {
return Package{}, err
}
return Package{
Project: c.project,
Name: name,
Version: version,
Size: size,
SHA256: digest,
}, nil
}
func copyDownloadedFile(source, destination string) error {
input, err := os.Open(source)
if err != nil {
return fmt.Errorf("读取临时下载文件失败: %w", err)
}
defer input.Close()
output, err := os.Create(destination)
if err != nil {
return fmt.Errorf("创建保存文件失败: %w", err)
}
_, copyErr := io.Copy(output, input)
closeErr := output.Close()
if copyErr != nil {
return fmt.Errorf("写入保存文件失败: %w", copyErr)
}
if closeErr != nil {
return fmt.Errorf("关闭保存文件失败: %w", closeErr)
}
return 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, c.resourcePath("/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 := c.resourcePath("/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) patchJSON(ctx context.Context, path string, value any, target any) error {
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("编码请求失败: %w", err)
}
request, err := c.newRequest(ctx, http.MethodPatch, path, bytes.NewReader(data))
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 err := json.NewDecoder(response.Body).Decode(target); err != nil {
return fmt.Errorf("解析服务端响应失败: %w", err)
}
return 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
}