feat(desktop): add client release updater
Desktop Client Build / Resolve Build Metadata (push) Successful in 19s
Frontend CI / Frontend (push) Successful in 3m20s
Backend CI / Backend (push) Successful in 16m48s
Desktop Client Build / Build Desktop Client (push) Successful in 24m46s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 37s
Desktop Client Build / Resolve Build Metadata (push) Successful in 19s
Frontend CI / Frontend (push) Successful in 3m20s
Backend CI / Backend (push) Successful in 16m48s
Desktop Client Build / Build Desktop Client (push) Successful in 24m46s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 37s
This commit is contained in:
@@ -285,14 +285,17 @@ type QdrantConfig struct {
|
||||
}
|
||||
|
||||
type ObjectStorageConfig struct {
|
||||
Provider string `mapstructure:"provider"`
|
||||
Endpoint string `mapstructure:"endpoint"`
|
||||
AccessKey string `mapstructure:"access_key"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
Bucket string `mapstructure:"bucket"`
|
||||
UseSSL bool `mapstructure:"use_ssl"`
|
||||
PublicBaseURL string `mapstructure:"public_base_url"`
|
||||
Region string `mapstructure:"region"`
|
||||
Provider string `mapstructure:"provider"`
|
||||
Endpoint string `mapstructure:"endpoint"`
|
||||
AccessKey string `mapstructure:"access_key"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
Bucket string `mapstructure:"bucket"`
|
||||
UseSSL bool `mapstructure:"use_ssl"`
|
||||
PublicBaseURL string `mapstructure:"public_base_url"`
|
||||
BucketACL string `mapstructure:"bucket_acl"`
|
||||
ObjectACL string `mapstructure:"object_acl"`
|
||||
SignedURLTTL time.Duration `mapstructure:"signed_url_ttl"`
|
||||
Region string `mapstructure:"region"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
@@ -956,6 +959,15 @@ func applyEnvOverrides(cfg *Config) {
|
||||
if publicBaseURL, ok := lookupNonEmptyEnv("OBJECT_STORAGE_PUBLIC_BASE_URL"); ok {
|
||||
cfg.ObjectStorage.PublicBaseURL = publicBaseURL
|
||||
}
|
||||
if bucketACL, ok := lookupNonEmptyEnv("OBJECT_STORAGE_BUCKET_ACL"); ok {
|
||||
cfg.ObjectStorage.BucketACL = bucketACL
|
||||
}
|
||||
if objectACL, ok := lookupNonEmptyEnv("OBJECT_STORAGE_OBJECT_ACL"); ok {
|
||||
cfg.ObjectStorage.ObjectACL = objectACL
|
||||
}
|
||||
if ttl, ok := lookupDurationEnv("OBJECT_STORAGE_SIGNED_URL_TTL"); ok {
|
||||
cfg.ObjectStorage.SignedURLTTL = ttl
|
||||
}
|
||||
if region, ok := lookupNonEmptyEnv("OBJECT_STORAGE_REGION"); ok {
|
||||
cfg.ObjectStorage.Region = region
|
||||
}
|
||||
|
||||
@@ -108,6 +108,34 @@ retrieval:
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesObjectStorageEnvOverrides(t *testing.T) {
|
||||
t.Setenv("OBJECT_STORAGE_BUCKET_ACL", "private")
|
||||
t.Setenv("OBJECT_STORAGE_OBJECT_ACL", "public-read")
|
||||
t.Setenv("OBJECT_STORAGE_SIGNED_URL_TTL", "45m")
|
||||
|
||||
configPath := writeTestConfig(t, `
|
||||
object_storage:
|
||||
bucket_acl: public-read
|
||||
object_acl: private
|
||||
signed_url_ttl: 10m
|
||||
`)
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.ObjectStorage.BucketACL != "private" {
|
||||
t.Fatalf("expected env bucket acl, got %q", cfg.ObjectStorage.BucketACL)
|
||||
}
|
||||
if cfg.ObjectStorage.ObjectACL != "public-read" {
|
||||
t.Fatalf("expected env object acl, got %q", cfg.ObjectStorage.ObjectACL)
|
||||
}
|
||||
if cfg.ObjectStorage.SignedURLTTL != 45*time.Minute {
|
||||
t.Fatalf("expected env signed url ttl, got %s", cfg.ObjectStorage.SignedURLTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesBrandLibraryDefaults(t *testing.T) {
|
||||
configPath := writeTestConfig(t, `
|
||||
brand_library: {}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
"go.uber.org/zap"
|
||||
@@ -106,11 +107,20 @@ func (c *aliyunClient) PutBytes(ctx context.Context, objectKey string, content [
|
||||
return fmt.Errorf("get aliyun bucket: %w", err)
|
||||
}
|
||||
|
||||
if err := bucket.PutObject(objectKey, bytes.NewReader(content), oss.ContentType(contentType)); err != nil {
|
||||
options, err := aliyunPutObjectOptions(c.cfg, contentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bucket.PutObject(objectKey, bytes.NewReader(content), options...); err != nil {
|
||||
c.logError(ctx, "aliyun put object failed",
|
||||
zap.String("bucket", c.bucket),
|
||||
zap.String("object_key", objectKey),
|
||||
zap.Int("content_size", len(content)),
|
||||
zap.String("object_acl", strings.TrimSpace(c.cfg.ObjectACL)),
|
||||
zap.String("content_type", contentType),
|
||||
zap.String("aliyun_error_code", aliyunErrorCode(err)),
|
||||
zap.String("aliyun_request_id", aliyunRequestID(err)),
|
||||
zap.Int("aliyun_status_code", aliyunStatusCode(err)),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("put aliyun object: %w", err)
|
||||
@@ -119,6 +129,47 @@ func (c *aliyunClient) PutBytes(ctx context.Context, objectKey string, content [
|
||||
return nil
|
||||
}
|
||||
|
||||
func aliyunPutObjectOptions(cfg config.ObjectStorageConfig, contentType string) ([]oss.Option, error) {
|
||||
options := []oss.Option{oss.ContentType(contentType)}
|
||||
acl := strings.ToLower(strings.TrimSpace(cfg.ObjectACL))
|
||||
switch acl {
|
||||
case "", "default":
|
||||
return options, nil
|
||||
case "private":
|
||||
return append(options, oss.ObjectACL(oss.ACLPrivate)), nil
|
||||
case "public-read":
|
||||
return append(options, oss.ObjectACL(oss.ACLPublicRead)), nil
|
||||
case "public-read-write":
|
||||
return append(options, oss.ObjectACL(oss.ACLPublicReadWrite)), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported aliyun oss object_acl %q", cfg.ObjectACL)
|
||||
}
|
||||
}
|
||||
|
||||
func aliyunErrorCode(err error) string {
|
||||
serviceErr, ok := err.(oss.ServiceError)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return serviceErr.Code
|
||||
}
|
||||
|
||||
func aliyunRequestID(err error) string {
|
||||
serviceErr, ok := err.(oss.ServiceError)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return serviceErr.RequestID
|
||||
}
|
||||
|
||||
func aliyunStatusCode(err error) int {
|
||||
serviceErr, ok := err.(oss.ServiceError)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return serviceErr.StatusCode
|
||||
}
|
||||
|
||||
func (c *aliyunClient) GetBytes(ctx context.Context, objectKey string) ([]byte, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
@@ -235,9 +286,62 @@ func (c *aliyunClient) PublicURL(objectKey string) (string, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
publicRead, err := c.isPublicReadable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !publicRead {
|
||||
return c.signedURL(objectKey)
|
||||
}
|
||||
return buildPublicURL(c.cfg, objectKey, true)
|
||||
}
|
||||
|
||||
func (c *aliyunClient) isPublicReadable() (bool, error) {
|
||||
objectACL := strings.ToLower(strings.TrimSpace(c.cfg.ObjectACL))
|
||||
if isPublicReadACL(objectACL) {
|
||||
return true, nil
|
||||
}
|
||||
if objectACL != "" && objectACL != "default" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
bucketACL := strings.ToLower(strings.TrimSpace(c.cfg.BucketACL))
|
||||
if bucketACL != "" {
|
||||
return isPublicReadACL(bucketACL), nil
|
||||
}
|
||||
|
||||
result, err := c.client.GetBucketACL(c.bucket)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get aliyun bucket acl: %w", err)
|
||||
}
|
||||
return isPublicReadACL(result.ACL), nil
|
||||
}
|
||||
|
||||
func (c *aliyunClient) signedURL(objectKey string) (string, error) {
|
||||
objectKey = normalizeObjectKeyPath(objectKey)
|
||||
if objectKey == "" {
|
||||
return "", fmt.Errorf("object key is required")
|
||||
}
|
||||
bucket, err := c.client.Bucket(c.bucket)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get aliyun bucket: %w", err)
|
||||
}
|
||||
ttl := c.cfg.SignedURLTTL
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * time.Minute
|
||||
}
|
||||
return bucket.SignURL(objectKey, oss.HTTPGet, int64(ttl.Seconds()))
|
||||
}
|
||||
|
||||
func isPublicReadACL(acl string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(acl)) {
|
||||
case "public-read", "public-read-write":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *aliyunClient) logError(ctx context.Context, msg string, fields ...zap.Field) {
|
||||
if c == nil || c.logger == nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package objectstorage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
)
|
||||
|
||||
func TestAliyunPutObjectOptionsAcceptsSupportedObjectACL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, acl := range []string{"", "default", "private", "public-read", "public-read-write"} {
|
||||
acl := acl
|
||||
t.Run(acl, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
options, err := aliyunPutObjectOptions(config.ObjectStorageConfig{ObjectACL: acl}, "application/zip")
|
||||
if err != nil {
|
||||
t.Fatalf("aliyunPutObjectOptions(%q) error = %v", acl, err)
|
||||
}
|
||||
if len(options) == 0 {
|
||||
t.Fatalf("expected at least content type option")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunPutObjectOptionsRejectsUnsupportedObjectACL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := aliyunPutObjectOptions(config.ObjectStorageConfig{ObjectACL: "authenticated-read"}, "application/zip")
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported object acl error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPublicReadACL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
acl string
|
||||
want bool
|
||||
}{
|
||||
{acl: "", want: false},
|
||||
{acl: "default", want: false},
|
||||
{acl: "private", want: false},
|
||||
{acl: "public-read", want: true},
|
||||
{acl: "public-read-write", want: true},
|
||||
{acl: " PUBLIC-READ ", want: true},
|
||||
} {
|
||||
got := isPublicReadACL(tc.acl)
|
||||
if got != tc.want {
|
||||
t.Fatalf("isPublicReadACL(%q) = %v, want %v", tc.acl, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,12 +95,9 @@ func joinEscapedPath(basePath string, extra ...string) string {
|
||||
return
|
||||
}
|
||||
|
||||
for _, segment := range strings.Split(value, "/") {
|
||||
segment = strings.TrimSpace(segment)
|
||||
if segment == "" {
|
||||
continue
|
||||
}
|
||||
segments = append(segments, url.PathEscape(segment))
|
||||
normalized := normalizeObjectKeyPath(value)
|
||||
if normalized != "" {
|
||||
segments = append(segments, strings.Split(normalized, "/")...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,3 +111,24 @@ func joinEscapedPath(basePath string, extra ...string) string {
|
||||
}
|
||||
return "/" + strings.Join(segments, "/")
|
||||
}
|
||||
|
||||
func normalizeObjectKeyPath(value string) string {
|
||||
segments := make([]string, 0)
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.Trim(value, "/")
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, segment := range strings.Split(value, "/") {
|
||||
segment = strings.TrimSpace(segment)
|
||||
if segment == "" {
|
||||
continue
|
||||
}
|
||||
if decoded, err := url.PathUnescape(segment); err == nil {
|
||||
segment = decoded
|
||||
}
|
||||
segments = append(segments, segment)
|
||||
}
|
||||
return strings.Join(segments, "/")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package objectstorage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
)
|
||||
|
||||
func TestBuildPublicURLDoesNotDoubleEscapeEncodedObjectKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := buildPublicURL(config.ObjectStorageConfig{
|
||||
Endpoint: "https://oss-cn-shanghai.aliyuncs.com",
|
||||
Bucket: "shengxintui",
|
||||
UseSSL: true,
|
||||
}, "desktop-client/releases/stable/darwin/arm64/0.1.1/20260525015204-%E7%9C%81%E5%BF%83%E6%8E%A8-mac-0.1.19.zip", true)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "https://shengxintui.oss-cn-shanghai.aliyuncs.com/desktop-client/releases/stable/darwin/arm64/0.1.1/20260525015204-%E7%9C%81%E5%BF%83%E6%8E%A8-mac-0.1.19.zip"
|
||||
if got != want {
|
||||
t.Fatalf("url = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPublicURLEscapesRawUnicodeObjectKeyOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := buildPublicURL(config.ObjectStorageConfig{
|
||||
Endpoint: "https://oss-cn-shanghai.aliyuncs.com",
|
||||
Bucket: "shengxintui",
|
||||
UseSSL: true,
|
||||
}, "desktop-client/releases/stable/darwin/arm64/0.1.1/20260525015204-省心推-mac-0.1.19.zip", true)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "https://shengxintui.oss-cn-shanghai.aliyuncs.com/desktop-client/releases/stable/darwin/arm64/0.1.1/20260525015204-%E7%9C%81%E5%BF%83%E6%8E%A8-mac-0.1.19.zip"
|
||||
if got != want {
|
||||
t.Fatalf("url = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeObjectKeyPathDecodesEncodedSegments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := normalizeObjectKeyPath("/desktop-client/releases/%E7%9C%81%E5%BF%83%E6%8E%A8.zip")
|
||||
want := "desktop-client/releases/省心推.zip"
|
||||
if got != want {
|
||||
t.Fatalf("object key = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -30,12 +30,17 @@ var routeDocs = map[string]routeDoc{
|
||||
"GET /api/public/assets/:token": {"对外访问签名资源", "通过签名 token 直接访问对象存储中的图片/附件,无需登录。"},
|
||||
|
||||
// --- Desktop 客户端:注册 / 心跳 / 鉴权 ---
|
||||
"POST /api/desktop/clients/register": {"桌面客户端注册", "由租户用户登录后调用,为本机桌面客户端申请客户端凭证。"},
|
||||
"POST /api/desktop/clients/rotate": {"轮换客户端密钥", "客户端使用旧凭证调用,换取新的客户端密钥。"},
|
||||
"POST /api/desktop/clients/heartbeat": {"客户端心跳上报", "桌面客户端定时上报存活与版本,服务端用于在线状态展示。"},
|
||||
"POST /api/desktop/clients/offline": {"客户端主动离线", "桌面客户端退出/休眠时上报,立即将本机标记为离线。"},
|
||||
"POST /api/desktop/clients/revoke": {"吊销客户端", "由桌面端调用,立即注销当前客户端凭证。"},
|
||||
|
||||
"GET /api/desktop/releases/latest": {"查询最新客户端版本", "桌面客户端未登录时查询最新版本配置,不提前生成签名下载地址。"},
|
||||
"GET /api/desktop/releases/download-url": {"解析客户端下载地址", "用户开始下载时按最新版本配置即时生成下载地址,私有 OSS 会返回签名 URL。"},
|
||||
"GET /api/desktop/releases/updater/:platform/:arch/:channel/:version": {"自动更新 Feed", "桌面客户端开始自动更新时读取 Electron updater feed,私有 OSS 会在 feed 中写入短时签名下载地址。"},
|
||||
"GET /api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed": {"自动更新 Feed 文件", "兼容 Electron updater 对 latest*.yml 的二次请求,返回动态生成的 updater YAML。"},
|
||||
"POST /api/desktop/clients/register": {"桌面客户端注册", "由租户用户登录后调用,为本机桌面客户端申请客户端凭证。"},
|
||||
"POST /api/desktop/clients/rotate": {"轮换客户端密钥", "客户端使用旧凭证调用,换取新的客户端密钥。"},
|
||||
"POST /api/desktop/clients/heartbeat": {"客户端心跳上报", "桌面客户端定时上报存活与版本,服务端用于在线状态展示。"},
|
||||
"POST /api/desktop/clients/offline": {"客户端主动离线", "桌面客户端退出/休眠时上报,立即将本机标记为离线。"},
|
||||
"POST /api/desktop/clients/revoke": {"吊销客户端", "由桌面端调用,立即注销当前客户端凭证。"},
|
||||
"GET /api/desktop/clients/releases/latest": {"查询最新客户端版本(桌面鉴权)", "桌面客户端带 client token 查询最新版本,默认沿用本机注册渠道。"},
|
||||
"GET /api/desktop/clients/releases/download-url": {"解析客户端下载地址(桌面鉴权)", "桌面客户端开始更新下载时即时获取下载地址,私有 OSS 会返回签名 URL。"},
|
||||
// --- Desktop:派单与拉取 ---
|
||||
"GET /api/desktop/dispatch": {"派单 WebSocket", "桌面客户端通过 WebSocket 长连接订阅派单事件,断线后自动重连。"},
|
||||
"GET /api/desktop/accounts": {"拉取账号列表(桌面)", "桌面客户端拉取本机绑定/可用的媒体账号,支持增量同步。"},
|
||||
|
||||
@@ -500,6 +500,10 @@ func errorResponse(description string) map[string]any {
|
||||
func securityForPath(path string) []map[string][]string {
|
||||
if path == "/api/auth/password-key" ||
|
||||
path == "/api/auth/login" || path == "/api/auth/refresh" ||
|
||||
path == "/api/desktop/releases/latest" ||
|
||||
path == "/api/desktop/releases/download-url" ||
|
||||
path == "/api/desktop/releases/updater/:platform/:arch/:channel/:version" ||
|
||||
path == "/api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed" ||
|
||||
strings.HasPrefix(path, "/api/health/") ||
|
||||
strings.HasPrefix(path, "/api/public/") {
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user