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:
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/ipregion"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/observability"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/repository/postgres"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/repository/redis"
|
||||
@@ -83,6 +84,7 @@ func main() {
|
||||
kolSubscriptionsRepo := repository.NewKolSubscriptionRepository(pool)
|
||||
auditsRepo := repository.NewAuditRepository(pool)
|
||||
siteDomainMappingsRepo := repository.NewSiteDomainMappingRepository(monitoringPool)
|
||||
desktopClientReleasesRepo := repository.NewDesktopClientReleaseRepository(pool)
|
||||
schedulerRepo := repository.NewSchedulerRepository(pool)
|
||||
|
||||
ipRegionResolver, err := ipregion.NewResolver(cfg.IPRegion.V4XDBPath, cfg.IPRegion.V6XDBPath, logger)
|
||||
@@ -118,6 +120,8 @@ func main() {
|
||||
jobSvc := app.NewJobService(jobsRepo, auditSvc, rabbitMQ)
|
||||
kolSubscriptionSvc := app.NewKolSubscriptionService(kolSubscriptionsRepo, auditSvc).WithCache(appCache)
|
||||
siteDomainMappingSvc := app.NewSiteDomainMappingService(siteDomainMappingsRepo, auditSvc)
|
||||
objectStorageClient := objectstorage.New(cfg.ObjectStorage, logger)
|
||||
desktopClientReleaseSvc := app.NewDesktopClientReleaseService(desktopClientReleasesRepo, objectStorageClient, auditSvc)
|
||||
complianceSvc := app.NewComplianceService(pool, auditSvc, logger)
|
||||
schedulerSvc := app.NewSchedulerService(schedulerRepo, auditSvc)
|
||||
|
||||
@@ -166,6 +170,7 @@ func main() {
|
||||
KolSubs: kolSubscriptionSvc,
|
||||
Audits: auditSvc,
|
||||
SiteDomains: siteDomainMappingSvc,
|
||||
Releases: desktopClientReleaseSvc,
|
||||
Compliance: complianceSvc,
|
||||
Scheduler: schedulerSvc,
|
||||
})
|
||||
|
||||
@@ -163,6 +163,9 @@ object_storage:
|
||||
bucket: shengxintui
|
||||
use_ssl: true
|
||||
public_base_url: ""
|
||||
bucket_acl: private
|
||||
object_acl: default
|
||||
signed_url_ttl: 30m
|
||||
region: cn-shanghai
|
||||
|
||||
cache:
|
||||
|
||||
@@ -40,6 +40,19 @@ redis:
|
||||
cache:
|
||||
driver: redis
|
||||
|
||||
object_storage:
|
||||
provider: aliyun
|
||||
endpoint: https://oss-cn-shanghai.aliyuncs.com
|
||||
access_key: LTAI5tAEj8euR8B1gXzMoG84
|
||||
secret_key: lJPrKo9ViyHJE8UWIMmMCY0B6Je8v2
|
||||
bucket: shengxintui
|
||||
use_ssl: true
|
||||
public_base_url: ""
|
||||
bucket_acl: private
|
||||
object_acl: default
|
||||
signed_url_ttl: 30m
|
||||
region: cn-shanghai
|
||||
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeDesktopClientReleaseInputOSS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
customURL := "https://download.example.com/client.dmg"
|
||||
ossKey := "desktop/stable/client.dmg"
|
||||
got, err := normalizeDesktopClientReleaseInput(DesktopClientReleaseInput{
|
||||
Platform: " Darwin ",
|
||||
Arch: "",
|
||||
Channel: "",
|
||||
Version: "1.2.3",
|
||||
DownloadSource: "oss",
|
||||
OSSObjectKey: &ossKey,
|
||||
CustomDownloadURL: &customURL,
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "darwin", got.Platform)
|
||||
require.Equal(t, "universal", got.Arch)
|
||||
require.Equal(t, "stable", got.Channel)
|
||||
require.Equal(t, DesktopClientDownloadSourceOSS, got.DownloadSource)
|
||||
require.NotNil(t, got.OSSObjectKey)
|
||||
require.Equal(t, "desktop/stable/client.dmg", *got.OSSObjectKey)
|
||||
require.Nil(t, got.CustomDownloadURL)
|
||||
}
|
||||
|
||||
func TestNormalizeDesktopClientReleaseInputCustom(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
customURL := "https://download.example.com/client.exe"
|
||||
ossKey := "desktop/stable/client.exe"
|
||||
got, err := normalizeDesktopClientReleaseInput(DesktopClientReleaseInput{
|
||||
Platform: "win32",
|
||||
Arch: "x64",
|
||||
Channel: "stable",
|
||||
Version: "1.2.3",
|
||||
DownloadSource: "custom",
|
||||
OSSObjectKey: &ossKey,
|
||||
CustomDownloadURL: &customURL,
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DesktopClientDownloadSourceCustom, got.DownloadSource)
|
||||
require.Nil(t, got.OSSObjectKey)
|
||||
require.NotNil(t, got.CustomDownloadURL)
|
||||
require.Equal(t, customURL, *got.CustomDownloadURL)
|
||||
}
|
||||
|
||||
func TestNormalizeDesktopClientReleaseInputRejectsInvalidDownloadTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
invalidOSSKey := "../client.dmg"
|
||||
_, err := normalizeDesktopClientReleaseInput(DesktopClientReleaseInput{
|
||||
Platform: "darwin",
|
||||
Version: "1.2.3",
|
||||
DownloadSource: "oss",
|
||||
OSSObjectKey: &invalidOSSKey,
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
invalidURL := "javascript:alert(1)"
|
||||
_, err = normalizeDesktopClientReleaseInput(DesktopClientReleaseInput{
|
||||
Platform: "win32",
|
||||
Version: "1.2.3",
|
||||
DownloadSource: "custom",
|
||||
CustomDownloadURL: &invalidURL,
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDesktopClientReleaseVersionPolicy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Positive(t, compareVersionStrings("1.2.10", "1.2.9"))
|
||||
require.Negative(t, compareVersionStrings("1.2.9", "1.2.10"))
|
||||
|
||||
minVersion := "1.2.0"
|
||||
require.True(t, minSupportedVersionRequiresUpdate(&minVersion, "1.1.9"))
|
||||
require.False(t, minSupportedVersionRequiresUpdate(&minVersion, "1.2.0"))
|
||||
}
|
||||
|
||||
func TestNormalizeDesktopClientReleaseUploadInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
target, fileName, err := normalizeDesktopClientReleaseUploadInput(DesktopClientReleaseUploadInput{
|
||||
Platform: "darwin",
|
||||
Arch: "arm64",
|
||||
Channel: "stable",
|
||||
Version: "1.2.3",
|
||||
FileName: "省心推-1.2.3-arm64.dmg",
|
||||
Content: []byte("package"),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "darwin", target.Platform)
|
||||
require.Equal(t, "arm64", target.Arch)
|
||||
require.Equal(t, "stable", target.Channel)
|
||||
require.Equal(t, "省心推-1.2.3-arm64.dmg", fileName)
|
||||
}
|
||||
|
||||
func TestNormalizeDesktopClientReleaseUploadInputDecodesFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, fileName, err := normalizeDesktopClientReleaseUploadInput(DesktopClientReleaseUploadInput{
|
||||
Platform: "darwin",
|
||||
Arch: "arm64",
|
||||
Channel: "stable",
|
||||
Version: "1.2.3",
|
||||
FileName: "%E7%9C%81%E5%BF%83%E6%8E%A8-mac-0.1.19.zip",
|
||||
Content: []byte("package"),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "省心推-mac-0.1.19.zip", fileName)
|
||||
}
|
||||
|
||||
func TestNormalizeDesktopClientReleaseUploadInputRejectsInvalidFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, _, err := normalizeDesktopClientReleaseUploadInput(DesktopClientReleaseUploadInput{
|
||||
Platform: "darwin",
|
||||
Arch: "arm64",
|
||||
Channel: "stable",
|
||||
Version: "1.2.3",
|
||||
FileName: "client.txt",
|
||||
Content: []byte("package"),
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBuildDesktopClientUpdaterFeedYAMLUsesSignedURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fileName := "省心推-mac-1.2.3.zip"
|
||||
sha256 := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
size := int64(1024)
|
||||
publishedAt := time.Date(2026, 5, 25, 10, 30, 0, 0, time.UTC)
|
||||
content, err := buildDesktopClientUpdaterFeedYAML(&domain.DesktopClientRelease{
|
||||
Platform: "darwin",
|
||||
Version: "1.2.3",
|
||||
PublishedAt: &publishedAt,
|
||||
UpdatedAt: publishedAt,
|
||||
}, desktopClientReleaseAsset{
|
||||
FileName: &fileName,
|
||||
FileSizeBytes: &size,
|
||||
SHA256: &sha256,
|
||||
}, "https://oss.example.com/desktop/%E7%9C%81%E5%BF%83%E6%8E%A8.zip?Expires=123&Signature=abc")
|
||||
|
||||
require.NoError(t, err)
|
||||
text := string(content)
|
||||
require.Contains(t, text, "version: 1.2.3")
|
||||
require.Contains(t, text, "sha2: "+sha256)
|
||||
require.Contains(t, text, "size: 1024")
|
||||
require.Contains(t, text, "Expires=123")
|
||||
require.Contains(t, text, "Signature=abc")
|
||||
require.NotContains(t, text, "%25E7")
|
||||
}
|
||||
|
||||
func TestValidateDesktopClientUpdaterPackage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
zipName := "省心推-mac-1.2.3.zip"
|
||||
require.NoError(t, validateDesktopClientUpdaterPackage("darwin", &zipName, ""))
|
||||
|
||||
dmgName := "省心推-mac-1.2.3.dmg"
|
||||
require.Error(t, validateDesktopClientUpdaterPackage("darwin", &dmgName, ""))
|
||||
|
||||
exeURL := "https://download.example.com/%E7%9C%81%E5%BF%83%E6%8E%A8%20Setup%201.2.3.exe"
|
||||
require.NoError(t, validateDesktopClientUpdaterPackage("win32", nil, exeURL))
|
||||
}
|
||||
|
||||
func TestBuildDesktopClientReleaseObjectKeyReusesContentAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sum := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
first := buildDesktopClientReleaseObjectKey(sum, "省心推 Setup 1.2.3.exe")
|
||||
second := buildDesktopClientReleaseObjectKey(sum, "renamed.exe")
|
||||
|
||||
require.Equal(t, first, second)
|
||||
require.Equal(t, "desktop-client/releases/blobs/"+sum+".exe", first)
|
||||
}
|
||||
@@ -20,18 +20,19 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server sharedconfig.ServerConfig `mapstructure:"server"`
|
||||
Database sharedconfig.DatabaseConfig `mapstructure:"database"`
|
||||
MonitoringDatabase sharedconfig.DatabaseConfig `mapstructure:"monitoring_database"`
|
||||
Redis sharedconfig.RedisConfig `mapstructure:"redis"`
|
||||
Cache sharedconfig.CacheConfig `mapstructure:"cache"`
|
||||
RabbitMQ sharedconfig.RabbitMQConfig `mapstructure:"rabbitmq"`
|
||||
Log sharedconfig.LogConfig `mapstructure:"log"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Auth sharedconfig.AuthConfig `mapstructure:"auth"`
|
||||
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
|
||||
AdminUsers AdminUsersConfig `mapstructure:"admin_users"`
|
||||
IPRegion IPRegionConfig `mapstructure:"ip_region"`
|
||||
Server sharedconfig.ServerConfig `mapstructure:"server"`
|
||||
Database sharedconfig.DatabaseConfig `mapstructure:"database"`
|
||||
MonitoringDatabase sharedconfig.DatabaseConfig `mapstructure:"monitoring_database"`
|
||||
Redis sharedconfig.RedisConfig `mapstructure:"redis"`
|
||||
Cache sharedconfig.CacheConfig `mapstructure:"cache"`
|
||||
RabbitMQ sharedconfig.RabbitMQConfig `mapstructure:"rabbitmq"`
|
||||
ObjectStorage sharedconfig.ObjectStorageConfig `mapstructure:"object_storage"`
|
||||
Log sharedconfig.LogConfig `mapstructure:"log"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Auth sharedconfig.AuthConfig `mapstructure:"auth"`
|
||||
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
|
||||
AdminUsers AdminUsersConfig `mapstructure:"admin_users"`
|
||||
IPRegion IPRegionConfig `mapstructure:"ip_region"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
@@ -237,6 +238,9 @@ func Diff(previous, current *Config) []FieldChange {
|
||||
if previous.RabbitMQ != current.RabbitMQ {
|
||||
add("rabbitmq", false)
|
||||
}
|
||||
if previous.ObjectStorage != current.ObjectStorage {
|
||||
add("object_storage", false)
|
||||
}
|
||||
if previous.Log != current.Log {
|
||||
add("log", false)
|
||||
}
|
||||
@@ -466,9 +470,56 @@ func applyEnvOverrides(cfg *Config) error {
|
||||
if v := os.Getenv("OPS_RABBITMQ_URL"); v != "" {
|
||||
cfg.RabbitMQ.URL = v
|
||||
}
|
||||
applyOpsObjectStorageEnvOverrides(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyOpsObjectStorageEnvOverrides(cfg *Config) {
|
||||
if endpoint := firstNonEmptyEnv("OPS_OBJECT_STORAGE_ENDPOINT", "OBJECT_STORAGE_ENDPOINT"); endpoint != "" {
|
||||
cfg.ObjectStorage.Endpoint = endpoint
|
||||
}
|
||||
if accessKey := firstNonEmptyEnv("OPS_OBJECT_STORAGE_ACCESS_KEY", "OBJECT_STORAGE_ACCESS_KEY"); accessKey != "" {
|
||||
cfg.ObjectStorage.AccessKey = accessKey
|
||||
}
|
||||
if secretKey := firstNonEmptyEnv("OPS_OBJECT_STORAGE_SECRET_KEY", "OBJECT_STORAGE_SECRET_KEY"); secretKey != "" {
|
||||
cfg.ObjectStorage.SecretKey = secretKey
|
||||
}
|
||||
if bucket := firstNonEmptyEnv("OPS_OBJECT_STORAGE_BUCKET", "OBJECT_STORAGE_BUCKET"); bucket != "" {
|
||||
cfg.ObjectStorage.Bucket = bucket
|
||||
}
|
||||
if provider := firstNonEmptyEnv("OPS_OBJECT_STORAGE_PROVIDER", "OBJECT_STORAGE_PROVIDER"); provider != "" {
|
||||
cfg.ObjectStorage.Provider = provider
|
||||
}
|
||||
if publicBaseURL := firstNonEmptyEnv("OPS_OBJECT_STORAGE_PUBLIC_BASE_URL", "OBJECT_STORAGE_PUBLIC_BASE_URL"); publicBaseURL != "" {
|
||||
cfg.ObjectStorage.PublicBaseURL = publicBaseURL
|
||||
}
|
||||
if objectACL := firstNonEmptyEnv("OPS_OBJECT_STORAGE_OBJECT_ACL", "OBJECT_STORAGE_OBJECT_ACL"); objectACL != "" {
|
||||
cfg.ObjectStorage.ObjectACL = objectACL
|
||||
}
|
||||
if v := firstNonEmptyEnv("OPS_OBJECT_STORAGE_SIGNED_URL_TTL", "OBJECT_STORAGE_SIGNED_URL_TTL"); v != "" {
|
||||
if ttl, err := time.ParseDuration(v); err == nil {
|
||||
cfg.ObjectStorage.SignedURLTTL = ttl
|
||||
}
|
||||
}
|
||||
if region := firstNonEmptyEnv("OPS_OBJECT_STORAGE_REGION", "OBJECT_STORAGE_REGION"); region != "" {
|
||||
cfg.ObjectStorage.Region = region
|
||||
}
|
||||
if v := firstNonEmptyEnv("OPS_OBJECT_STORAGE_USE_SSL", "OBJECT_STORAGE_USE_SSL"); v != "" {
|
||||
if useSSL, err := parseBoolEnv("OPS_OBJECT_STORAGE_USE_SSL", v); err == nil {
|
||||
cfg.ObjectStorage.UseSSL = useSSL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyEnv(names ...string) string {
|
||||
for _, name := range names {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseBoolEnv(name, value string) (bool, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "y", "on":
|
||||
|
||||
@@ -73,3 +73,32 @@ type SiteDomainMapping struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type DesktopClientRelease struct {
|
||||
ID int64
|
||||
Platform string
|
||||
Arch string
|
||||
Channel string
|
||||
Version string
|
||||
MinSupportedVersion *string
|
||||
DownloadSource string
|
||||
OSSObjectKey *string
|
||||
CustomDownloadURL *string
|
||||
FileName *string
|
||||
FileSizeBytes *int64
|
||||
SHA256 *string
|
||||
UpdaterDownloadSource *string
|
||||
UpdaterOSSObjectKey *string
|
||||
UpdaterCustomDownloadURL *string
|
||||
UpdaterFileName *string
|
||||
UpdaterFileSizeBytes *int64
|
||||
UpdaterSHA256 *string
|
||||
ReleaseNotes *string
|
||||
ForceUpdate bool
|
||||
Enabled bool
|
||||
PublishedAt *time.Time
|
||||
CreatedBy *int64
|
||||
UpdatedBy *int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
||||
)
|
||||
|
||||
var ErrDesktopClientReleaseNotFound = errors.New("desktop client release not found")
|
||||
|
||||
type DesktopClientReleaseRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewDesktopClientReleaseRepository(pool *pgxpool.Pool) *DesktopClientReleaseRepository {
|
||||
return &DesktopClientReleaseRepository{pool: pool}
|
||||
}
|
||||
|
||||
const desktopClientReleaseColumns = `
|
||||
id, platform, arch, channel, version, min_supported_version, download_source,
|
||||
oss_object_key, custom_download_url, file_name, file_size_bytes, sha256,
|
||||
updater_download_source, updater_oss_object_key, updater_custom_download_url,
|
||||
updater_file_name, updater_file_size_bytes, updater_sha256,
|
||||
release_notes, force_update, enabled, published_at, created_by, updated_by,
|
||||
created_at, updated_at`
|
||||
|
||||
type DesktopClientReleaseFilter struct {
|
||||
Keyword string
|
||||
Platform string
|
||||
Channel string
|
||||
Enabled *bool
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type DesktopClientReleaseTarget struct {
|
||||
Platform string
|
||||
Arch string
|
||||
Channel string
|
||||
}
|
||||
|
||||
type UpsertDesktopClientReleaseInput struct {
|
||||
Platform string
|
||||
Arch string
|
||||
Channel string
|
||||
Version string
|
||||
MinSupportedVersion *string
|
||||
DownloadSource string
|
||||
OSSObjectKey *string
|
||||
CustomDownloadURL *string
|
||||
FileName *string
|
||||
FileSizeBytes *int64
|
||||
SHA256 *string
|
||||
UpdaterDownloadSource *string
|
||||
UpdaterOSSObjectKey *string
|
||||
UpdaterCustomDownloadURL *string
|
||||
UpdaterFileName *string
|
||||
UpdaterFileSizeBytes *int64
|
||||
UpdaterSHA256 *string
|
||||
ReleaseNotes *string
|
||||
ForceUpdate bool
|
||||
Enabled bool
|
||||
PublishedAtNow bool
|
||||
ActorID *int64
|
||||
}
|
||||
|
||||
func scanDesktopClientRelease(row pgx.Row) (*domain.DesktopClientRelease, error) {
|
||||
var item domain.DesktopClientRelease
|
||||
err := row.Scan(
|
||||
&item.ID,
|
||||
&item.Platform,
|
||||
&item.Arch,
|
||||
&item.Channel,
|
||||
&item.Version,
|
||||
&item.MinSupportedVersion,
|
||||
&item.DownloadSource,
|
||||
&item.OSSObjectKey,
|
||||
&item.CustomDownloadURL,
|
||||
&item.FileName,
|
||||
&item.FileSizeBytes,
|
||||
&item.SHA256,
|
||||
&item.UpdaterDownloadSource,
|
||||
&item.UpdaterOSSObjectKey,
|
||||
&item.UpdaterCustomDownloadURL,
|
||||
&item.UpdaterFileName,
|
||||
&item.UpdaterFileSizeBytes,
|
||||
&item.UpdaterSHA256,
|
||||
&item.ReleaseNotes,
|
||||
&item.ForceUpdate,
|
||||
&item.Enabled,
|
||||
&item.PublishedAt,
|
||||
&item.CreatedBy,
|
||||
&item.UpdatedBy,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrDesktopClientReleaseNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *DesktopClientReleaseRepository) List(ctx context.Context, f DesktopClientReleaseFilter) ([]domain.DesktopClientRelease, int64, error) {
|
||||
args := []any{}
|
||||
where := "1=1"
|
||||
|
||||
if f.Keyword != "" {
|
||||
args = append(args, "%"+f.Keyword+"%")
|
||||
where += fmt.Sprintf(
|
||||
" AND (version ILIKE $%d OR platform ILIKE $%d OR arch ILIKE $%d OR channel ILIKE $%d OR COALESCE(file_name, '') ILIKE $%d OR COALESCE(oss_object_key, '') ILIKE $%d OR COALESCE(custom_download_url, '') ILIKE $%d OR COALESCE(updater_file_name, '') ILIKE $%d OR COALESCE(updater_oss_object_key, '') ILIKE $%d OR COALESCE(updater_custom_download_url, '') ILIKE $%d)",
|
||||
len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args),
|
||||
)
|
||||
}
|
||||
if f.Platform != "" {
|
||||
args = append(args, f.Platform)
|
||||
where += fmt.Sprintf(" AND platform = $%d", len(args))
|
||||
}
|
||||
if f.Channel != "" {
|
||||
args = append(args, f.Channel)
|
||||
where += fmt.Sprintf(" AND channel = $%d", len(args))
|
||||
}
|
||||
if f.Enabled != nil {
|
||||
args = append(args, *f.Enabled)
|
||||
where += fmt.Sprintf(" AND enabled = $%d", len(args))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) FROM ops.desktop_client_releases WHERE "+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 20
|
||||
}
|
||||
offset := f.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
args = append(args, limit, offset)
|
||||
limitArg := len(args) - 1
|
||||
offsetArg := len(args)
|
||||
|
||||
rows, err := r.pool.Query(ctx, fmt.Sprintf(`
|
||||
SELECT %s
|
||||
FROM ops.desktop_client_releases
|
||||
WHERE %s
|
||||
ORDER BY enabled DESC, channel ASC, platform ASC, arch ASC, updated_at DESC, id DESC
|
||||
LIMIT $%d OFFSET $%d`, desktopClientReleaseColumns, where, limitArg, offsetArg), args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.DesktopClientRelease, 0, limit)
|
||||
for rows.Next() {
|
||||
item, scanErr := scanDesktopClientRelease(rows)
|
||||
if scanErr != nil {
|
||||
return nil, 0, scanErr
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
return items, total, rows.Err()
|
||||
}
|
||||
|
||||
func (r *DesktopClientReleaseRepository) GetByID(ctx context.Context, id int64) (*domain.DesktopClientRelease, error) {
|
||||
return scanDesktopClientRelease(r.pool.QueryRow(ctx, `
|
||||
SELECT `+desktopClientReleaseColumns+`
|
||||
FROM ops.desktop_client_releases
|
||||
WHERE id = $1`, id))
|
||||
}
|
||||
|
||||
func (r *DesktopClientReleaseRepository) GetByTarget(ctx context.Context, target DesktopClientReleaseTarget) (*domain.DesktopClientRelease, error) {
|
||||
return scanDesktopClientRelease(r.pool.QueryRow(ctx, `
|
||||
SELECT `+desktopClientReleaseColumns+`
|
||||
FROM ops.desktop_client_releases
|
||||
WHERE platform = $1 AND arch = $2 AND channel = $3`,
|
||||
target.Platform, target.Arch, target.Channel))
|
||||
}
|
||||
|
||||
func (r *DesktopClientReleaseRepository) FindEnabled(ctx context.Context, target DesktopClientReleaseTarget) (*domain.DesktopClientRelease, error) {
|
||||
return scanDesktopClientRelease(r.pool.QueryRow(ctx, `
|
||||
SELECT `+desktopClientReleaseColumns+`
|
||||
FROM ops.desktop_client_releases
|
||||
WHERE platform = $1
|
||||
AND channel = $2
|
||||
AND enabled = TRUE
|
||||
AND (arch = $3 OR arch = 'universal')
|
||||
ORDER BY CASE WHEN arch = $3 THEN 0 ELSE 1 END, updated_at DESC, id DESC
|
||||
LIMIT 1`,
|
||||
target.Platform, target.Channel, target.Arch))
|
||||
}
|
||||
|
||||
func (r *DesktopClientReleaseRepository) Create(ctx context.Context, in UpsertDesktopClientReleaseInput) (*domain.DesktopClientRelease, error) {
|
||||
return scanDesktopClientRelease(r.pool.QueryRow(ctx, `
|
||||
INSERT INTO ops.desktop_client_releases (
|
||||
platform, arch, channel, version, min_supported_version, download_source,
|
||||
oss_object_key, custom_download_url, file_name, file_size_bytes, sha256,
|
||||
updater_download_source, updater_oss_object_key, updater_custom_download_url,
|
||||
updater_file_name, updater_file_size_bytes, updater_sha256,
|
||||
release_notes, force_update, enabled, published_at, created_by, updated_by
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15, $16, $17,
|
||||
$18, $19, $20,
|
||||
CASE WHEN $21 THEN NOW() ELSE NULL END,
|
||||
$22, $22
|
||||
)
|
||||
RETURNING `+desktopClientReleaseColumns,
|
||||
in.Platform, in.Arch, in.Channel, in.Version, in.MinSupportedVersion, in.DownloadSource,
|
||||
in.OSSObjectKey, in.CustomDownloadURL, in.FileName, in.FileSizeBytes, in.SHA256,
|
||||
in.UpdaterDownloadSource, in.UpdaterOSSObjectKey, in.UpdaterCustomDownloadURL,
|
||||
in.UpdaterFileName, in.UpdaterFileSizeBytes, in.UpdaterSHA256,
|
||||
in.ReleaseNotes, in.ForceUpdate, in.Enabled, in.PublishedAtNow, in.ActorID))
|
||||
}
|
||||
|
||||
func (r *DesktopClientReleaseRepository) Update(ctx context.Context, id int64, in UpsertDesktopClientReleaseInput) (*domain.DesktopClientRelease, error) {
|
||||
return scanDesktopClientRelease(r.pool.QueryRow(ctx, `
|
||||
UPDATE ops.desktop_client_releases
|
||||
SET platform = $1,
|
||||
arch = $2,
|
||||
channel = $3,
|
||||
version = $4,
|
||||
min_supported_version = $5,
|
||||
download_source = $6,
|
||||
oss_object_key = $7,
|
||||
custom_download_url = $8,
|
||||
file_name = $9,
|
||||
file_size_bytes = $10,
|
||||
sha256 = $11,
|
||||
updater_download_source = $12,
|
||||
updater_oss_object_key = $13,
|
||||
updater_custom_download_url = $14,
|
||||
updater_file_name = $15,
|
||||
updater_file_size_bytes = $16,
|
||||
updater_sha256 = $17,
|
||||
release_notes = $18,
|
||||
force_update = $19,
|
||||
enabled = $20,
|
||||
published_at = CASE
|
||||
WHEN $20 = TRUE AND published_at IS NULL THEN NOW()
|
||||
WHEN $20 = FALSE THEN NULL
|
||||
ELSE published_at
|
||||
END,
|
||||
updated_by = $21,
|
||||
updated_at = NOW()
|
||||
WHERE id = $22
|
||||
RETURNING `+desktopClientReleaseColumns,
|
||||
in.Platform, in.Arch, in.Channel, in.Version, in.MinSupportedVersion, in.DownloadSource,
|
||||
in.OSSObjectKey, in.CustomDownloadURL, in.FileName, in.FileSizeBytes, in.SHA256,
|
||||
in.UpdaterDownloadSource, in.UpdaterOSSObjectKey, in.UpdaterCustomDownloadURL,
|
||||
in.UpdaterFileName, in.UpdaterFileSizeBytes, in.UpdaterSHA256,
|
||||
in.ReleaseNotes, in.ForceUpdate, in.Enabled, in.ActorID, id))
|
||||
}
|
||||
|
||||
func (r *DesktopClientReleaseRepository) SetEnabled(ctx context.Context, id int64, enabled bool, actorID *int64) (*domain.DesktopClientRelease, error) {
|
||||
return scanDesktopClientRelease(r.pool.QueryRow(ctx, `
|
||||
UPDATE ops.desktop_client_releases
|
||||
SET enabled = $1,
|
||||
published_at = CASE
|
||||
WHEN $1 = TRUE AND published_at IS NULL THEN NOW()
|
||||
WHEN $1 = FALSE THEN NULL
|
||||
ELSE published_at
|
||||
END,
|
||||
updated_by = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING `+desktopClientReleaseColumns, enabled, actorID, id))
|
||||
}
|
||||
|
||||
func (r *DesktopClientReleaseRepository) Delete(ctx context.Context, id int64) error {
|
||||
cmd, err := r.pool.Exec(ctx, `DELETE FROM ops.desktop_client_releases WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.RowsAffected() == 0 {
|
||||
return ErrDesktopClientReleaseNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/app"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
type desktopClientReleaseRequest struct {
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
Arch string `json:"arch"`
|
||||
Channel string `json:"channel"`
|
||||
Version string `json:"version" binding:"required"`
|
||||
MinSupportedVersion *string `json:"min_supported_version"`
|
||||
DownloadSource string `json:"download_source"`
|
||||
OSSObjectKey *string `json:"oss_object_key"`
|
||||
CustomDownloadURL *string `json:"custom_download_url"`
|
||||
FileName *string `json:"file_name"`
|
||||
FileSizeBytes *int64 `json:"file_size_bytes"`
|
||||
SHA256 *string `json:"sha256"`
|
||||
UpdaterDownloadSource *string `json:"updater_download_source"`
|
||||
UpdaterOSSObjectKey *string `json:"updater_oss_object_key"`
|
||||
UpdaterCustomDownloadURL *string `json:"updater_custom_download_url"`
|
||||
UpdaterFileName *string `json:"updater_file_name"`
|
||||
UpdaterFileSizeBytes *int64 `json:"updater_file_size_bytes"`
|
||||
UpdaterSHA256 *string `json:"updater_sha256"`
|
||||
ReleaseNotes *string `json:"release_notes"`
|
||||
ForceUpdate bool `json:"force_update"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type setDesktopClientReleaseEnabledRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func listDesktopClientReleasesHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
result, err := svc.List(c.Request.Context(), app.DesktopClientReleaseListInput{
|
||||
Keyword: c.Query("keyword"),
|
||||
Platform: c.Query("platform"),
|
||||
Channel: c.Query("channel"),
|
||||
Enabled: parseOptionalBoolQuery(c.Query("enabled")),
|
||||
Page: page,
|
||||
Size: size,
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func createDesktopClientReleaseHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body desktopClientReleaseRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := svc.Create(c.Request.Context(), actorFromGin(c), body.toInput())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func updateDesktopClientReleaseHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
var body desktopClientReleaseRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := svc.Update(c.Request.Context(), actorFromGin(c), id, body.toInput())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func setDesktopClientReleaseEnabledHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
var body setDesktopClientReleaseEnabledRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := svc.SetEnabled(c.Request.Context(), actorFromGin(c), id, body.Enabled)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteDesktopClientReleaseHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
if err := svc.Delete(c.Request.Context(), actorFromGin(c), id); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDesktopClientReleaseDownloadURLHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.ResolveDownloadURLByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func uploadDesktopClientReleaseHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40071, "release_file_required", "请选择要上传的客户端安装包"))
|
||||
return
|
||||
}
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40071, "release_file_open_failed", "客户端安装包读取失败"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40071, "release_file_read_failed", "客户端安装包读取失败"))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := svc.UploadOSS(c.Request.Context(), app.DesktopClientReleaseUploadInput{
|
||||
Platform: c.PostForm("platform"),
|
||||
Arch: c.PostForm("arch"),
|
||||
Channel: c.PostForm("channel"),
|
||||
Version: c.PostForm("version"),
|
||||
Kind: c.DefaultPostForm("kind", app.DesktopClientAssetKindInstaller),
|
||||
FileName: fileHeader.Filename,
|
||||
Content: content,
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func checkDesktopClientReleaseHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := svc.CheckLatest(c.Request.Context(), app.DesktopClientReleaseCheckInput{
|
||||
Platform: c.Query("platform"),
|
||||
Arch: c.Query("arch"),
|
||||
Channel: c.DefaultQuery("channel", "stable"),
|
||||
CurrentVersion: c.Query("version"),
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveLatestDesktopClientReleaseDownloadURLHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := svc.ResolveLatestDownloadURL(c.Request.Context(), app.DesktopClientReleaseCheckInput{
|
||||
Platform: c.Query("platform"),
|
||||
Arch: c.Query("arch"),
|
||||
Channel: c.DefaultQuery("channel", "stable"),
|
||||
CurrentVersion: c.Query("version"),
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func desktopClientReleaseUpdaterFeedHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
feed, err := svc.ResolveLatestUpdaterFeed(c.Request.Context(), app.DesktopClientReleaseCheckInput{
|
||||
Platform: firstNonEmpty(c.Param("platform"), c.Query("platform")),
|
||||
Arch: firstNonEmpty(c.Param("arch"), c.Query("arch")),
|
||||
Channel: firstNonEmpty(c.Param("channel"), c.DefaultQuery("channel", "stable")),
|
||||
CurrentVersion: firstNonEmpty(c.Param("version"), c.Query("version")),
|
||||
}, c.Param("feed"))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/x-yaml; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, sanitizeHeaderFileName(feed.FileName)))
|
||||
c.Data(http.StatusOK, "application/x-yaml; charset=utf-8", feed.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func (r desktopClientReleaseRequest) toInput() app.DesktopClientReleaseInput {
|
||||
enabled := true
|
||||
if r.Enabled != nil {
|
||||
enabled = *r.Enabled
|
||||
}
|
||||
return app.DesktopClientReleaseInput{
|
||||
Platform: r.Platform,
|
||||
Arch: r.Arch,
|
||||
Channel: r.Channel,
|
||||
Version: r.Version,
|
||||
MinSupportedVersion: r.MinSupportedVersion,
|
||||
DownloadSource: r.DownloadSource,
|
||||
OSSObjectKey: r.OSSObjectKey,
|
||||
CustomDownloadURL: r.CustomDownloadURL,
|
||||
FileName: r.FileName,
|
||||
FileSizeBytes: r.FileSizeBytes,
|
||||
SHA256: r.SHA256,
|
||||
UpdaterDownloadSource: r.UpdaterDownloadSource,
|
||||
UpdaterOSSObjectKey: r.UpdaterOSSObjectKey,
|
||||
UpdaterCustomDownloadURL: r.UpdaterCustomDownloadURL,
|
||||
UpdaterFileName: r.UpdaterFileName,
|
||||
UpdaterFileSizeBytes: r.UpdaterFileSizeBytes,
|
||||
UpdaterSHA256: r.UpdaterSHA256,
|
||||
ReleaseNotes: r.ReleaseNotes,
|
||||
ForceUpdate: r.ForceUpdate,
|
||||
Enabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizeHeaderFileName(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "latest.yml"
|
||||
}
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == '"' || r == '\\' || r == '\r' || r == '\n' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ type Deps struct {
|
||||
KolSubs *app.KolSubscriptionService
|
||||
Audits *app.AuditService
|
||||
SiteDomains *app.SiteDomainMappingService
|
||||
Releases *app.DesktopClientReleaseService
|
||||
Compliance *app.ComplianceService
|
||||
Scheduler *app.SchedulerService
|
||||
}
|
||||
@@ -67,6 +68,10 @@ func RegisterRoutes(d Deps) {
|
||||
}
|
||||
response.Success(c, gin.H{"status": "ready"})
|
||||
})
|
||||
d.Engine.GET("/api/desktop/releases/latest", checkDesktopClientReleaseHandler(d.Releases))
|
||||
d.Engine.GET("/api/desktop/releases/download-url", resolveLatestDesktopClientReleaseDownloadURLHandler(d.Releases))
|
||||
d.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed", desktopClientReleaseUpdaterFeedHandler(d.Releases))
|
||||
d.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version", desktopClientReleaseUpdaterFeedHandler(d.Releases))
|
||||
|
||||
api := d.Engine.Group("/api/ops")
|
||||
{
|
||||
@@ -132,6 +137,14 @@ func RegisterRoutes(d Deps) {
|
||||
authed.POST("/site-domain-mappings/:id/active", setSiteDomainMappingActiveHandler(d.SiteDomains))
|
||||
authed.DELETE("/site-domain-mappings/:id", deleteSiteDomainMappingHandler(d.SiteDomains))
|
||||
|
||||
authed.GET("/desktop-client/releases", listDesktopClientReleasesHandler(d.Releases))
|
||||
authed.POST("/desktop-client/releases/upload", uploadDesktopClientReleaseHandler(d.Releases))
|
||||
authed.POST("/desktop-client/releases", createDesktopClientReleaseHandler(d.Releases))
|
||||
authed.PATCH("/desktop-client/releases/:id", updateDesktopClientReleaseHandler(d.Releases))
|
||||
authed.GET("/desktop-client/releases/:id/download-url", resolveDesktopClientReleaseDownloadURLHandler(d.Releases))
|
||||
authed.POST("/desktop-client/releases/:id/enabled", setDesktopClientReleaseEnabledHandler(d.Releases))
|
||||
authed.DELETE("/desktop-client/releases/:id", deleteDesktopClientReleaseHandler(d.Releases))
|
||||
|
||||
compliance := authed.Group("/compliance")
|
||||
compliance.GET("/dictionaries", listComplianceDictionariesHandler(d.Compliance))
|
||||
compliance.POST("/dictionaries", createComplianceDictionaryHandler(d.Compliance))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/bootstrap"
|
||||
opsapp "github.com/geo-platform/tenant-api/internal/ops/app"
|
||||
opsrepo "github.com/geo-platform/tenant-api/internal/ops/repository"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
type DesktopReleaseHandler struct {
|
||||
svc *opsapp.DesktopClientReleaseService
|
||||
}
|
||||
|
||||
func NewDesktopReleaseHandler(a *bootstrap.App) *DesktopReleaseHandler {
|
||||
return &DesktopReleaseHandler{
|
||||
svc: opsapp.NewDesktopClientReleaseService(
|
||||
opsrepo.NewDesktopClientReleaseRepository(a.DB),
|
||||
a.ObjectStorage,
|
||||
nil,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DesktopReleaseHandler) Latest(c *gin.Context) {
|
||||
client := MustDesktopClient(c.Request.Context())
|
||||
data, err := h.svc.CheckLatest(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{
|
||||
Platform: c.Query("platform"),
|
||||
Arch: c.Query("arch"),
|
||||
Channel: defaultDesktopReleaseChannel(c.Query("channel"), client.Channel),
|
||||
CurrentVersion: c.Query("version"),
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *DesktopReleaseHandler) DownloadURL(c *gin.Context) {
|
||||
client := MustDesktopClient(c.Request.Context())
|
||||
data, err := h.svc.ResolveLatestDownloadURL(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{
|
||||
Platform: c.Query("platform"),
|
||||
Arch: c.Query("arch"),
|
||||
Channel: defaultDesktopReleaseChannel(c.Query("channel"), client.Channel),
|
||||
CurrentVersion: c.Query("version"),
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *DesktopReleaseHandler) LatestPublic(c *gin.Context) {
|
||||
data, err := h.svc.CheckLatest(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{
|
||||
Platform: c.Query("platform"),
|
||||
Arch: c.Query("arch"),
|
||||
Channel: c.DefaultQuery("channel", "stable"),
|
||||
CurrentVersion: c.Query("version"),
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *DesktopReleaseHandler) DownloadURLPublic(c *gin.Context) {
|
||||
data, err := h.svc.ResolveLatestDownloadURL(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{
|
||||
Platform: c.Query("platform"),
|
||||
Arch: c.Query("arch"),
|
||||
Channel: c.DefaultQuery("channel", "stable"),
|
||||
CurrentVersion: c.Query("version"),
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *DesktopReleaseHandler) UpdaterFeedPublic(c *gin.Context) {
|
||||
feed, err := h.svc.ResolveLatestUpdaterFeed(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{
|
||||
Platform: firstNonEmpty(c.Param("platform"), c.Query("platform")),
|
||||
Arch: firstNonEmpty(c.Param("arch"), c.Query("arch")),
|
||||
Channel: firstNonEmpty(c.Param("channel"), c.DefaultQuery("channel", "stable")),
|
||||
CurrentVersion: firstNonEmpty(c.Param("version"), c.Query("version")),
|
||||
}, c.Param("feed"))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/x-yaml; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, sanitizeHeaderFileName(feed.FileName)))
|
||||
c.Data(http.StatusOK, "application/x-yaml; charset=utf-8", feed.Content)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func defaultDesktopReleaseChannel(queryValue string, clientChannel *string) string {
|
||||
if queryValue != "" {
|
||||
return queryValue
|
||||
}
|
||||
if clientChannel != nil && *clientChannel != "" {
|
||||
return *clientChannel
|
||||
}
|
||||
return "stable"
|
||||
}
|
||||
|
||||
func sanitizeHeaderFileName(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "latest.yml"
|
||||
}
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == '"' || r == '\\' || r == '\r' || r == '\n' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
}
|
||||
@@ -33,6 +33,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
protected.POST("/auth/logout", authHandler.Logout)
|
||||
|
||||
desktopClientHandler := NewDesktopClientHandler(app)
|
||||
desktopReleaseHandler := NewDesktopReleaseHandler(app)
|
||||
desktopAccountHandler := NewDesktopAccountHandler(app)
|
||||
desktopTaskHandler := NewDesktopTaskHandler(app)
|
||||
desktopDispatchHandler := NewDesktopDispatchHandler(app)
|
||||
@@ -40,6 +41,10 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
desktopContentHandler := NewDesktopContentHandler(app)
|
||||
publishJobHandler := NewPublishJobHandler(app)
|
||||
protected.POST("/desktop/clients/register", desktopClientHandler.Register)
|
||||
app.Engine.GET("/api/desktop/releases/latest", desktopReleaseHandler.LatestPublic)
|
||||
app.Engine.GET("/api/desktop/releases/download-url", desktopReleaseHandler.DownloadURLPublic)
|
||||
app.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed", desktopReleaseHandler.UpdaterFeedPublic)
|
||||
app.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version", desktopReleaseHandler.UpdaterFeedPublic)
|
||||
|
||||
desktopAuth := app.Engine.Group("/api/desktop")
|
||||
desktopAuth.Use(DesktopClientMiddleware(repository.NewDesktopClientRepository(app.DB)))
|
||||
@@ -47,6 +52,8 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
desktopAuth.POST("/clients/heartbeat", desktopClientHandler.Heartbeat)
|
||||
desktopAuth.POST("/clients/offline", desktopClientHandler.Offline)
|
||||
desktopAuth.POST("/clients/revoke", desktopClientHandler.Revoke)
|
||||
desktopAuth.GET("/clients/releases/latest", desktopReleaseHandler.Latest)
|
||||
desktopAuth.GET("/clients/releases/download-url", desktopReleaseHandler.DownloadURL)
|
||||
desktopAuth.GET("/dispatch", desktopDispatchHandler.Dispatch)
|
||||
desktopAuth.GET("/accounts", desktopAccountHandler.List)
|
||||
desktopAuth.GET("/content/articles/:id", desktopContentHandler.Article)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS ops.desktop_client_releases;
|
||||
@@ -0,0 +1,64 @@
|
||||
CREATE TABLE IF NOT EXISTS ops.desktop_client_releases (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
platform VARCHAR(32) NOT NULL,
|
||||
arch VARCHAR(32) NOT NULL DEFAULT 'universal',
|
||||
channel VARCHAR(32) NOT NULL DEFAULT 'stable',
|
||||
version VARCHAR(64) NOT NULL,
|
||||
min_supported_version VARCHAR(64),
|
||||
download_source VARCHAR(16) NOT NULL DEFAULT 'oss',
|
||||
oss_object_key TEXT,
|
||||
custom_download_url TEXT,
|
||||
file_name TEXT,
|
||||
file_size_bytes BIGINT,
|
||||
sha256 VARCHAR(64),
|
||||
updater_download_source VARCHAR(16),
|
||||
updater_oss_object_key TEXT,
|
||||
updater_custom_download_url TEXT,
|
||||
updater_file_name TEXT,
|
||||
updater_file_size_bytes BIGINT,
|
||||
updater_sha256 VARCHAR(64),
|
||||
release_notes TEXT,
|
||||
force_update BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
published_at TIMESTAMPTZ,
|
||||
created_by BIGINT,
|
||||
updated_by BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT ck_desktop_client_release_file_size
|
||||
CHECK (file_size_bytes IS NULL OR file_size_bytes >= 0),
|
||||
CONSTRAINT ck_desktop_client_release_sha256
|
||||
CHECK (sha256 IS NULL OR sha256 ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT ck_desktop_client_release_updater_file_size
|
||||
CHECK (updater_file_size_bytes IS NULL OR updater_file_size_bytes >= 0),
|
||||
CONSTRAINT ck_desktop_client_release_updater_sha256
|
||||
CHECK (updater_sha256 IS NULL OR updater_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT ck_desktop_client_release_source
|
||||
CHECK (download_source IN ('oss', 'custom')),
|
||||
CONSTRAINT ck_desktop_client_release_updater_source
|
||||
CHECK (updater_download_source IS NULL OR updater_download_source IN ('oss', 'custom')),
|
||||
CONSTRAINT ck_desktop_client_release_download_target
|
||||
CHECK (
|
||||
(download_source = 'oss' AND NULLIF(BTRIM(COALESCE(oss_object_key, '')), '') IS NOT NULL)
|
||||
OR
|
||||
(download_source = 'custom' AND NULLIF(BTRIM(COALESCE(custom_download_url, '')), '') IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT ck_desktop_client_release_updater_target
|
||||
CHECK (
|
||||
updater_download_source IS NULL
|
||||
OR
|
||||
(updater_download_source = 'oss' AND NULLIF(BTRIM(COALESCE(updater_oss_object_key, '')), '') IS NOT NULL)
|
||||
OR
|
||||
(updater_download_source = 'custom' AND NULLIF(BTRIM(COALESCE(updater_custom_download_url, '')), '') IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_desktop_client_releases_target
|
||||
ON ops.desktop_client_releases(platform, arch, channel);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_desktop_client_releases_lookup
|
||||
ON ops.desktop_client_releases(channel, platform, arch)
|
||||
WHERE enabled = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_desktop_client_releases_updated_at
|
||||
ON ops.desktop_client_releases(updated_at DESC);
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE ops.desktop_client_releases
|
||||
DROP CONSTRAINT IF EXISTS ck_desktop_client_release_updater_target,
|
||||
DROP CONSTRAINT IF EXISTS ck_desktop_client_release_updater_source,
|
||||
DROP CONSTRAINT IF EXISTS ck_desktop_client_release_updater_sha256,
|
||||
DROP CONSTRAINT IF EXISTS ck_desktop_client_release_updater_file_size,
|
||||
DROP COLUMN IF EXISTS updater_sha256,
|
||||
DROP COLUMN IF EXISTS updater_file_size_bytes,
|
||||
DROP COLUMN IF EXISTS updater_file_name,
|
||||
DROP COLUMN IF EXISTS updater_custom_download_url,
|
||||
DROP COLUMN IF EXISTS updater_oss_object_key,
|
||||
DROP COLUMN IF EXISTS updater_download_source;
|
||||
@@ -0,0 +1,60 @@
|
||||
ALTER TABLE ops.desktop_client_releases
|
||||
ADD COLUMN IF NOT EXISTS updater_download_source VARCHAR(16),
|
||||
ADD COLUMN IF NOT EXISTS updater_oss_object_key TEXT,
|
||||
ADD COLUMN IF NOT EXISTS updater_custom_download_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS updater_file_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS updater_file_size_bytes BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS updater_sha256 VARCHAR(64);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'ck_desktop_client_release_updater_file_size'
|
||||
AND conrelid = 'ops.desktop_client_releases'::regclass
|
||||
) THEN
|
||||
ALTER TABLE ops.desktop_client_releases
|
||||
ADD CONSTRAINT ck_desktop_client_release_updater_file_size
|
||||
CHECK (updater_file_size_bytes IS NULL OR updater_file_size_bytes >= 0);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'ck_desktop_client_release_updater_sha256'
|
||||
AND conrelid = 'ops.desktop_client_releases'::regclass
|
||||
) THEN
|
||||
ALTER TABLE ops.desktop_client_releases
|
||||
ADD CONSTRAINT ck_desktop_client_release_updater_sha256
|
||||
CHECK (updater_sha256 IS NULL OR updater_sha256 ~ '^[0-9a-f]{64}$');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'ck_desktop_client_release_updater_source'
|
||||
AND conrelid = 'ops.desktop_client_releases'::regclass
|
||||
) THEN
|
||||
ALTER TABLE ops.desktop_client_releases
|
||||
ADD CONSTRAINT ck_desktop_client_release_updater_source
|
||||
CHECK (updater_download_source IS NULL OR updater_download_source IN ('oss', 'custom'));
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'ck_desktop_client_release_updater_target'
|
||||
AND conrelid = 'ops.desktop_client_releases'::regclass
|
||||
) THEN
|
||||
ALTER TABLE ops.desktop_client_releases
|
||||
ADD CONSTRAINT ck_desktop_client_release_updater_target
|
||||
CHECK (
|
||||
updater_download_source IS NULL
|
||||
OR
|
||||
(updater_download_source = 'oss' AND NULLIF(BTRIM(COALESCE(updater_oss_object_key, '')), '') IS NOT NULL)
|
||||
OR
|
||||
(updater_download_source = 'custom' AND NULLIF(BTRIM(COALESCE(updater_custom_download_url, '')), '') IS NOT NULL)
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
Reference in New Issue
Block a user