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

- 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:
2026-06-20 12:44:28 +08:00
parent f26802c76e
commit 62d0b22988
15 changed files with 884 additions and 58 deletions
+73 -5
View File
@@ -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