feat: Implement direct upload functionality for desktop client releases
Desktop Client Build / Resolve Build Metadata (push) Successful in 52s
Frontend CI / Frontend (push) Successful in 3m49s
Backend CI / Backend (push) Successful in 16m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m22s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 31s
Desktop Client Build / Resolve Build Metadata (push) Successful in 52s
Frontend CI / Frontend (push) Successful in 3m49s
Backend CI / Backend (push) Successful in 16m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m22s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 31s
- Added new endpoints for initiating and completing direct uploads of desktop client packages. - Introduced request and response structures for direct upload operations. - Enhanced the upload process to support SHA256 and Content-MD5 validation. - Updated the frontend to reflect changes in upload button states and progress indicators. - Modified object storage clients to support presigned PUT URLs with content type and MD5 headers. - Added tests for direct upload functionality and ensured existing upload processes remain intact.
This commit is contained in:
@@ -154,19 +154,41 @@ func (c *aliyunClient) bucketExists(ctx context.Context) (bool, error) {
|
||||
}
|
||||
|
||||
func aliyunPutObjectOptions(cfg config.ObjectStorageConfig, contentType string) ([]oss.Option, error) {
|
||||
options, _, err := aliyunPutObjectOptionsAndHeaders(cfg, contentType, "")
|
||||
return options, err
|
||||
}
|
||||
|
||||
func aliyunPutObjectOptionsAndHeaders(
|
||||
cfg config.ObjectStorageConfig,
|
||||
contentType string,
|
||||
contentMD5Base64 string,
|
||||
) ([]oss.Option, http.Header, error) {
|
||||
contentType = strings.TrimSpace(contentType)
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
options := []oss.Option{oss.ContentType(contentType)}
|
||||
headers := http.Header{}
|
||||
headers.Set(oss.HTTPHeaderContentType, contentType)
|
||||
if contentMD5Base64 = strings.TrimSpace(contentMD5Base64); contentMD5Base64 != "" {
|
||||
options = append(options, oss.ContentMD5(contentMD5Base64))
|
||||
headers.Set(oss.HTTPHeaderContentMD5, contentMD5Base64)
|
||||
}
|
||||
acl := strings.ToLower(strings.TrimSpace(cfg.ObjectACL))
|
||||
switch acl {
|
||||
case "", "default":
|
||||
return options, nil
|
||||
return options, headers, nil
|
||||
case "private":
|
||||
return append(options, oss.ObjectACL(oss.ACLPrivate)), nil
|
||||
headers.Set(oss.HTTPHeaderOssObjectACL, string(oss.ACLPrivate))
|
||||
return append(options, oss.ObjectACL(oss.ACLPrivate)), headers, nil
|
||||
case "public-read":
|
||||
return append(options, oss.ObjectACL(oss.ACLPublicRead)), nil
|
||||
headers.Set(oss.HTTPHeaderOssObjectACL, string(oss.ACLPublicRead))
|
||||
return append(options, oss.ObjectACL(oss.ACLPublicRead)), headers, nil
|
||||
case "public-read-write":
|
||||
return append(options, oss.ObjectACL(oss.ACLPublicReadWrite)), nil
|
||||
headers.Set(oss.HTTPHeaderOssObjectACL, string(oss.ACLPublicReadWrite))
|
||||
return append(options, oss.ObjectACL(oss.ACLPublicReadWrite)), headers, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported aliyun oss object_acl %q", cfg.ObjectACL)
|
||||
return nil, nil, fmt.Errorf("unsupported aliyun oss object_acl %q", cfg.ObjectACL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +363,52 @@ func (c *aliyunClient) DownloadURL(objectKey string) (string, error) {
|
||||
return c.signedURL(objectKey, oss.ResponseContentDisposition(downloadContentDisposition(objectKey)))
|
||||
}
|
||||
|
||||
func (c *aliyunClient) PresignedPutURL(
|
||||
ctx context.Context,
|
||||
objectKey string,
|
||||
contentType string,
|
||||
contentMD5Base64 string,
|
||||
ttl time.Duration,
|
||||
) (*PresignedPutResult, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objectKey = normalizeObjectKeyPath(objectKey)
|
||||
if objectKey == "" {
|
||||
return nil, fmt.Errorf("object key is required")
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = c.cfg.SignedURLTTL
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * time.Minute
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bucket, err := c.client.Bucket(c.bucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get aliyun bucket: %w", err)
|
||||
}
|
||||
options, headers, err := aliyunPutObjectOptionsAndHeaders(c.cfg, contentType, contentMD5Base64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signedURL, err := bucket.SignURL(objectKey, oss.HTTPPut, int64(ttl.Seconds()), options...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("presign aliyun put url: %w", err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PresignedPutResult{
|
||||
URL: signedURL,
|
||||
Headers: headers,
|
||||
ExpiresAt: time.Now().UTC().Add(ttl),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *aliyunClient) Usage(ctx context.Context) (*UsageInfo, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -32,6 +33,16 @@ type ReaderClient interface {
|
||||
PutReader(ctx context.Context, objectKey string, reader io.Reader, size int64, contentType string) error
|
||||
}
|
||||
|
||||
type PresignedPutResult struct {
|
||||
URL string
|
||||
Headers http.Header
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type PresignedPutClient interface {
|
||||
PresignedPutURL(ctx context.Context, objectKey string, contentType string, contentMD5Base64 string, ttl time.Duration) (*PresignedPutResult, error)
|
||||
}
|
||||
|
||||
type ManagementClient interface {
|
||||
Client
|
||||
List(ctx context.Context, in ListInput) (*ListResult, error)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -290,6 +291,51 @@ func (c *minioClient) DownloadURL(objectKey string) (string, error) {
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (c *minioClient) PresignedPutURL(
|
||||
ctx context.Context,
|
||||
objectKey string,
|
||||
contentType string,
|
||||
contentMD5Base64 string,
|
||||
ttl time.Duration,
|
||||
) (*PresignedPutResult, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objectKey = normalizeObjectKeyPath(objectKey)
|
||||
if objectKey == "" {
|
||||
return nil, fmt.Errorf("object key is required")
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = c.cfg.SignedURLTTL
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * time.Minute
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
if contentType = strings.TrimSpace(contentType); contentType != "" {
|
||||
headers.Set("Content-Type", contentType)
|
||||
}
|
||||
if contentMD5Base64 = strings.TrimSpace(contentMD5Base64); contentMD5Base64 != "" {
|
||||
headers.Set("Content-MD5", contentMD5Base64)
|
||||
}
|
||||
var u *url.URL
|
||||
var err error
|
||||
if len(headers) > 0 {
|
||||
u, err = c.client.PresignHeader(ctx, http.MethodPut, c.bucket, objectKey, ttl, nil, headers)
|
||||
} else {
|
||||
u, err = c.client.PresignedPutObject(ctx, c.bucket, objectKey, ttl)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("presign minio put url: %w", err)
|
||||
}
|
||||
return &PresignedPutResult{
|
||||
URL: u.String(),
|
||||
Headers: headers,
|
||||
ExpiresAt: time.Now().UTC().Add(ttl),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *minioClient) List(ctx context.Context, in ListInput) (*ListResult, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -100,7 +100,9 @@ var routeDocs = map[string]routeDoc{
|
||||
|
||||
// --- Ops:桌面客户端版本 ---
|
||||
"GET /api/ops/desktop-client/releases": {"桌面客户端版本列表", "分页查询桌面客户端版本配置。"},
|
||||
"POST /api/ops/desktop-client/releases/upload": {"上传桌面客户端包(兼容)", "兼容旧版单请求 Multipart 上传;新版前端使用分片上传。"},
|
||||
"POST /api/ops/desktop-client/releases/upload": {"上传桌面客户端包(兼容)", "兼容旧版单请求 Multipart 上传。"},
|
||||
"POST /api/ops/desktop-client/releases/direct-upload": {"初始化客户端包 OSS 直传", "校验文件信息并生成浏览器直传 OSS 的短期 PUT 签名 URL。"},
|
||||
"POST /api/ops/desktop-client/releases/direct-upload/complete": {"完成客户端包 OSS 直传", "校验直传对象是否存在、大小是否匹配,并返回 Object Key、文件大小和 SHA256。"},
|
||||
"POST /api/ops/desktop-client/release-uploads": {"初始化客户端包分片上传", "创建分片上传会话,返回 upload_id、分片大小和分片数量。"},
|
||||
"POST /api/ops/desktop-client/release-uploads/:upload_id/chunks/:chunk_index": {"上传客户端包分片", "Multipart 上传单个客户端安装包或更新包分片。"},
|
||||
"POST /api/ops/desktop-client/release-uploads/:upload_id/complete": {"完成客户端包分片上传", "校验全部分片,流式写入 OSS,并返回 Object Key、文件大小和 SHA256。"},
|
||||
|
||||
Reference in New Issue
Block a user