Files
geo/server/internal/ops/app/desktop_client_release.go
T
root d6866cd85b
Frontend CI / Frontend (push) Successful in 4m1s
Backend CI / Backend (push) Failing after 8m51s
feat: add downloadable release management for desktop client
- Extend DesktopClientReleaseCheckResponse with platform, arch, and channel fields.
- Introduce DesktopClientDownloadableRelease and DesktopClientDownloadableReleaseListResponse interfaces for managing downloadable releases.
- Implement ListDownloadable method in DesktopClientReleaseService to retrieve downloadable releases based on the specified channel.
- Update DesktopClientReleaseCheckResult to include platform, arch, and channel information.
- Add resolveInstallerDownloadURL method to handle download URL resolution for releases.
- Create new endpoint in DesktopReleaseHandler for listing downloadable releases.
- Update router to include the new downloadable releases route.
- Enhance tests to cover the new functionality for resolving download URLs and listing downloadable releases.
2026-05-31 19:28:35 +08:00

1274 lines
44 KiB
Go

package app
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/jackc/pgx/v5/pgconn"
"gopkg.in/yaml.v3"
"github.com/geo-platform/tenant-api/internal/ops/domain"
"github.com/geo-platform/tenant-api/internal/ops/repository"
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
const (
ActionDesktopClientReleaseCreate = "desktop_client_release.create"
ActionDesktopClientReleaseUpdate = "desktop_client_release.update"
ActionDesktopClientReleaseEnable = "desktop_client_release.enable"
ActionDesktopClientReleaseDisable = "desktop_client_release.disable"
ActionDesktopClientReleaseDelete = "desktop_client_release.delete"
)
const (
DesktopClientDownloadSourceOSS = "oss"
DesktopClientDownloadSourceCustom = "custom"
)
const (
DesktopClientAssetKindInstaller = "installer"
DesktopClientAssetKindUpdater = "updater"
)
type DesktopClientReleaseService struct {
releases *repository.DesktopClientReleaseRepository
storage objectstorage.Client
audits *AuditService
}
func NewDesktopClientReleaseService(
releases *repository.DesktopClientReleaseRepository,
storage objectstorage.Client,
audits *AuditService,
) *DesktopClientReleaseService {
return &DesktopClientReleaseService{releases: releases, storage: storage, audits: audits}
}
type DesktopClientReleaseView struct {
ID int64 `json:"id"`
Platform string `json:"platform"`
Arch string `json:"arch"`
Channel string `json:"channel"`
Version string `json:"version"`
MinSupportedVersion *string `json:"min_supported_version"`
DownloadSource string `json:"download_source"`
OSSObjectKey *string `json:"oss_object_key"`
CustomDownloadURL *string `json:"custom_download_url"`
DownloadURL *string `json:"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"`
UpdaterDownloadURL *string `json:"updater_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"`
PublishedAt *time.Time `json:"published_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type DesktopClientReleaseListInput struct {
Keyword string
Platform string
Channel string
Enabled *bool
Page int
Size int
}
type DesktopClientReleaseListResult struct {
Items []DesktopClientReleaseView `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
type DesktopClientDownloadableRelease struct {
ID int64 `json:"id"`
Platform string `json:"platform"`
Arch string `json:"arch"`
Channel string `json:"channel"`
Version string `json:"version"`
DownloadURL *string `json:"download_url"`
FileName *string `json:"file_name"`
FileSizeBytes *int64 `json:"file_size_bytes"`
SHA256 *string `json:"sha256"`
ReleaseNotes *string `json:"release_notes"`
PublishedAt *time.Time `json:"published_at"`
UpdatedAt time.Time `json:"updated_at"`
ForceUpdate bool `json:"force_update"`
MinSupportedVersion *string `json:"min_supported_version"`
DownloadSource string `json:"download_source"`
OSSObjectKey *string `json:"oss_object_key"`
}
type DesktopClientDownloadableReleaseListResult struct {
Items []DesktopClientDownloadableRelease `json:"items"`
}
type DesktopClientReleaseInput 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
}
type DesktopClientReleaseUploadInput struct {
Platform string
Arch string
Channel string
Version string
Kind string
FileName string
Content []byte
}
type DesktopClientReleaseUploadResult struct {
OSSObjectKey string `json:"oss_object_key"`
FileName string `json:"file_name"`
FileSizeBytes int64 `json:"file_size_bytes"`
SHA256 string `json:"sha256"`
}
type DesktopClientReleaseCheckInput struct {
Platform string
Arch string
Channel string
CurrentVersion string
}
type DesktopClientReleaseCheckResult struct {
UpdateAvailable bool `json:"update_available"`
LatestVersion string `json:"latest_version"`
CurrentVersion string `json:"current_version"`
Platform string `json:"platform"`
Arch string `json:"arch"`
Channel string `json:"channel"`
MinSupportedVersion *string `json:"min_supported_version"`
ForceUpdate bool `json:"force_update"`
DownloadSource string `json:"download_source"`
OSSObjectKey *string `json:"oss_object_key"`
FileName *string `json:"file_name"`
FileSizeBytes *int64 `json:"file_size_bytes"`
SHA256 *string `json:"sha256"`
UpdaterAvailable bool `json:"updater_available"`
UpdaterDownloadSource *string `json:"updater_download_source"`
UpdaterOSSObjectKey *string `json:"updater_oss_object_key"`
UpdaterFileName *string `json:"updater_file_name"`
UpdaterFileSizeBytes *int64 `json:"updater_file_size_bytes"`
UpdaterSHA256 *string `json:"updater_sha256"`
ReleaseNotes *string `json:"release_notes"`
PublishedAt *time.Time `json:"published_at"`
}
type DesktopClientReleaseDownloadURLResult struct {
DownloadURL string `json:"download_url"`
ExpiresIn *int64 `json:"expires_in,omitempty"`
}
type DesktopClientUpdaterFeedResult struct {
Content []byte
FileName string
}
type desktopClientUpdaterFeed struct {
Version string `yaml:"version"`
Files []desktopClientUpdaterFile `yaml:"files"`
Path string `yaml:"path,omitempty"`
SHA2 string `yaml:"sha2,omitempty"`
ReleaseDate string `yaml:"releaseDate,omitempty"`
ReleaseName string `yaml:"releaseName,omitempty"`
ReleaseNotes string `yaml:"releaseNotes,omitempty"`
}
type desktopClientUpdaterFile struct {
URL string `yaml:"url"`
SHA2 string `yaml:"sha2,omitempty"`
Size *int64 `yaml:"size,omitempty"`
}
type desktopClientReleaseAsset struct {
DownloadSource *string
OSSObjectKey *string
CustomDownloadURL *string
FileName *string
FileSizeBytes *int64
SHA256 *string
}
func (a desktopClientReleaseAsset) HasTarget() bool {
switch stringPointerValue(a.DownloadSource) {
case DesktopClientDownloadSourceCustom:
return strings.TrimSpace(stringPointerValue(a.CustomDownloadURL)) != ""
default:
return strings.TrimSpace(stringPointerValue(a.OSSObjectKey)) != ""
}
}
func (s *DesktopClientReleaseService) List(ctx context.Context, in DesktopClientReleaseListInput) (*DesktopClientReleaseListResult, error) {
page := in.Page
if page <= 0 {
page = 1
}
size := in.Size
if size <= 0 || size > 200 {
size = 20
}
items, total, err := s.releases.List(ctx, repository.DesktopClientReleaseFilter{
Keyword: strings.TrimSpace(in.Keyword),
Platform: normalizeDesktopReleaseEnum(in.Platform),
Channel: normalizeDesktopReleaseEnum(in.Channel),
Enabled: in.Enabled,
Limit: size,
Offset: (page - 1) * size,
})
if err != nil {
return nil, err
}
views := make([]DesktopClientReleaseView, 0, len(items))
for i := range items {
views = append(views, s.toView(&items[i]))
}
return &DesktopClientReleaseListResult{Items: views, Total: total, Page: page, Size: size}, nil
}
func (s *DesktopClientReleaseService) ListDownloadable(ctx context.Context, channel string) (*DesktopClientDownloadableReleaseListResult, error) {
enabled := true
normalizedChannel := normalizeDesktopReleaseEnum(channel)
if normalizedChannel == "" {
normalizedChannel = "stable"
}
if !isValidDesktopReleaseToken(normalizedChannel) {
return nil, response.ErrBadRequest(40062, "invalid_desktop_release_channel", "发布渠道格式无效")
}
items, _, err := s.releases.List(ctx, repository.DesktopClientReleaseFilter{
Channel: normalizedChannel,
Enabled: &enabled,
Limit: 200,
})
if err != nil {
return nil, err
}
views := make([]DesktopClientDownloadableRelease, 0, len(items))
for i := range items {
var downloadURL *string
if items[i].DownloadSource == DesktopClientDownloadSourceCustom {
if value := strings.TrimSpace(stringPointerValue(items[i].CustomDownloadURL)); value != "" {
downloadURL = &value
}
}
views = append(views, DesktopClientDownloadableRelease{
ID: items[i].ID,
Platform: items[i].Platform,
Arch: items[i].Arch,
Channel: items[i].Channel,
Version: items[i].Version,
DownloadURL: downloadURL,
FileName: items[i].FileName,
FileSizeBytes: items[i].FileSizeBytes,
SHA256: items[i].SHA256,
ReleaseNotes: items[i].ReleaseNotes,
PublishedAt: items[i].PublishedAt,
UpdatedAt: items[i].UpdatedAt,
ForceUpdate: items[i].ForceUpdate,
MinSupportedVersion: items[i].MinSupportedVersion,
DownloadSource: items[i].DownloadSource,
OSSObjectKey: items[i].OSSObjectKey,
})
}
return &DesktopClientDownloadableReleaseListResult{Items: views}, nil
}
func (s *DesktopClientReleaseService) Create(ctx context.Context, actor *Actor, in DesktopClientReleaseInput) (*DesktopClientReleaseView, error) {
normalized, err := normalizeDesktopClientReleaseInput(in)
if err != nil {
return nil, err
}
item, err := s.releases.Create(ctx, normalized.toRepositoryInput(actorID(actor)))
if err != nil {
if isDuplicateDesktopClientRelease(err) {
return nil, response.ErrConflict(40930, "duplicate_desktop_client_release", "该平台、架构和渠道的客户端版本配置已存在")
}
return nil, err
}
s.audit(ctx, actor, ActionDesktopClientReleaseCreate, item)
view := s.toView(item)
return &view, nil
}
func (s *DesktopClientReleaseService) Update(ctx context.Context, actor *Actor, id int64, in DesktopClientReleaseInput) (*DesktopClientReleaseView, error) {
normalized, err := normalizeDesktopClientReleaseInput(in)
if err != nil {
return nil, err
}
item, err := s.releases.Update(ctx, id, normalized.toRepositoryInput(actorID(actor)))
if err != nil {
if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) {
return nil, response.ErrNotFound(40430, "desktop_client_release_not_found", "客户端版本配置不存在")
}
if isDuplicateDesktopClientRelease(err) {
return nil, response.ErrConflict(40930, "duplicate_desktop_client_release", "该平台、架构和渠道的客户端版本配置已存在")
}
return nil, err
}
s.audit(ctx, actor, ActionDesktopClientReleaseUpdate, item)
view := s.toView(item)
return &view, nil
}
func (s *DesktopClientReleaseService) SetEnabled(ctx context.Context, actor *Actor, id int64, enabled bool) (*DesktopClientReleaseView, error) {
item, err := s.releases.SetEnabled(ctx, id, enabled, actorID(actor))
if err != nil {
if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) {
return nil, response.ErrNotFound(40430, "desktop_client_release_not_found", "客户端版本配置不存在")
}
return nil, err
}
action := ActionDesktopClientReleaseEnable
if !enabled {
action = ActionDesktopClientReleaseDisable
}
s.audit(ctx, actor, action, item)
view := s.toView(item)
return &view, nil
}
func (s *DesktopClientReleaseService) Delete(ctx context.Context, actor *Actor, id int64) error {
item, err := s.releases.GetByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) {
return response.ErrNotFound(40430, "desktop_client_release_not_found", "客户端版本配置不存在")
}
return err
}
if err := s.releases.Delete(ctx, id); err != nil {
if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) {
return response.ErrNotFound(40430, "desktop_client_release_not_found", "客户端版本配置不存在")
}
return err
}
s.audit(ctx, actor, ActionDesktopClientReleaseDelete, item)
return nil
}
func (s *DesktopClientReleaseService) UploadOSS(ctx context.Context, in DesktopClientReleaseUploadInput) (*DesktopClientReleaseUploadResult, error) {
if s.storage == nil {
return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable")
}
if err := s.storage.Validate(); err != nil {
return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable")
}
target, fileName, err := normalizeDesktopClientReleaseUploadInput(in)
if err != nil {
return nil, err
}
kind := normalizeDesktopClientAssetKind(in.Kind)
if kind == DesktopClientAssetKindUpdater {
if err := validateDesktopClientUpdaterPackage(target.Platform, &fileName, ""); err != nil {
return nil, err
}
}
sum := sha256.Sum256(in.Content)
sha256Hex := hex.EncodeToString(sum[:])
objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, fileName)
contentType := detectDesktopReleaseContentType(fileName, in.Content)
exists, err := s.storage.Exists(ctx, objectKey)
if err != nil {
appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error())
appErr.Cause = err
return nil, appErr
}
if !exists {
if err := s.storage.PutBytes(ctx, objectKey, in.Content, contentType); err != nil {
appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error())
appErr.Cause = err
return nil, appErr
}
}
return &DesktopClientReleaseUploadResult{
OSSObjectKey: objectKey,
FileName: fileName,
FileSizeBytes: int64(len(in.Content)),
SHA256: sha256Hex,
}, nil
}
func (s *DesktopClientReleaseService) CheckLatest(ctx context.Context, in DesktopClientReleaseCheckInput) (*DesktopClientReleaseCheckResult, error) {
target, currentVersion, err := normalizeDesktopClientReleaseCheckInput(in)
if err != nil {
return nil, err
}
item, err := s.releases.FindEnabled(ctx, target)
if err != nil {
if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) {
return nil, response.ErrNotFound(40431, "desktop_client_release_not_configured", "未配置该平台客户端版本")
}
return nil, err
}
return &DesktopClientReleaseCheckResult{
UpdateAvailable: compareVersionStrings(item.Version, currentVersion) > 0,
LatestVersion: item.Version,
CurrentVersion: currentVersion,
Platform: item.Platform,
Arch: item.Arch,
Channel: item.Channel,
MinSupportedVersion: item.MinSupportedVersion,
ForceUpdate: item.ForceUpdate || minSupportedVersionRequiresUpdate(item.MinSupportedVersion, currentVersion),
DownloadSource: item.DownloadSource,
OSSObjectKey: item.OSSObjectKey,
FileName: item.FileName,
FileSizeBytes: item.FileSizeBytes,
SHA256: item.SHA256,
UpdaterAvailable: itemUpdaterAsset(item).HasTarget(),
UpdaterDownloadSource: itemUpdaterAsset(item).DownloadSource,
UpdaterOSSObjectKey: itemUpdaterAsset(item).OSSObjectKey,
UpdaterFileName: itemUpdaterAsset(item).FileName,
UpdaterFileSizeBytes: itemUpdaterAsset(item).FileSizeBytes,
UpdaterSHA256: itemUpdaterAsset(item).SHA256,
ReleaseNotes: item.ReleaseNotes,
PublishedAt: item.PublishedAt,
}, nil
}
func (s *DesktopClientReleaseService) toView(item *domain.DesktopClientRelease) DesktopClientReleaseView {
return DesktopClientReleaseView{
ID: item.ID,
Platform: item.Platform,
Arch: item.Arch,
Channel: item.Channel,
Version: item.Version,
MinSupportedVersion: item.MinSupportedVersion,
DownloadSource: item.DownloadSource,
OSSObjectKey: item.OSSObjectKey,
CustomDownloadURL: item.CustomDownloadURL,
DownloadURL: nil,
FileName: item.FileName,
FileSizeBytes: item.FileSizeBytes,
SHA256: item.SHA256,
UpdaterDownloadSource: item.UpdaterDownloadSource,
UpdaterOSSObjectKey: item.UpdaterOSSObjectKey,
UpdaterCustomDownloadURL: item.UpdaterCustomDownloadURL,
UpdaterDownloadURL: nil,
UpdaterFileName: item.UpdaterFileName,
UpdaterFileSizeBytes: item.UpdaterFileSizeBytes,
UpdaterSHA256: item.UpdaterSHA256,
ReleaseNotes: item.ReleaseNotes,
ForceUpdate: item.ForceUpdate,
Enabled: item.Enabled,
PublishedAt: item.PublishedAt,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
func (s *DesktopClientReleaseService) ResolveDownloadURLByID(ctx context.Context, id int64) (*DesktopClientReleaseDownloadURLResult, error) {
item, err := s.releases.GetByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) {
return nil, response.ErrNotFound(40430, "desktop_client_release_not_found", "客户端版本配置不存在")
}
return nil, err
}
return s.resolveDownloadURLResult(item)
}
func (s *DesktopClientReleaseService) ResolveLatestDownloadURL(ctx context.Context, in DesktopClientReleaseCheckInput) (*DesktopClientReleaseDownloadURLResult, error) {
target, _, err := normalizeDesktopClientReleaseCheckInput(in)
if err != nil {
return nil, err
}
item, err := s.releases.FindEnabled(ctx, target)
if err != nil {
if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) {
return nil, response.ErrNotFound(40431, "desktop_client_release_not_configured", "未配置该平台客户端版本")
}
return nil, err
}
return s.resolveDownloadURLResult(item)
}
func (s *DesktopClientReleaseService) ResolveLatestUpdaterFeed(ctx context.Context, in DesktopClientReleaseCheckInput, feedFile string) (*DesktopClientUpdaterFeedResult, error) {
target, _, err := normalizeDesktopClientReleaseCheckInput(in)
if err != nil {
return nil, err
}
item, err := s.releases.FindEnabled(ctx, target)
if err != nil {
if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) {
return nil, response.ErrNotFound(40431, "desktop_client_release_not_configured", "未配置该平台客户端版本")
}
return nil, err
}
updaterAsset := itemUpdaterAsset(item)
if !updaterAsset.HasTarget() {
return nil, response.ErrInternal(50096, "desktop_client_update_package_not_configured", "未配置当前平台自动更新包")
}
downloadURL, err := s.resolveAssetDownloadURL(updaterAsset)
if err != nil {
return nil, err
}
if err := validateDesktopClientUpdaterPackage(item.Platform, updaterAsset.FileName, downloadURL); err != nil {
return nil, err
}
content, err := buildDesktopClientUpdaterFeedYAML(item, updaterAsset, downloadURL)
if err != nil {
return nil, err
}
return &DesktopClientUpdaterFeedResult{
Content: content,
FileName: sanitizeDesktopUpdaterFeedFile(feedFile),
}, nil
}
func itemInstallerAsset(item *domain.DesktopClientRelease) desktopClientReleaseAsset {
if item == nil {
return desktopClientReleaseAsset{}
}
source := item.DownloadSource
return desktopClientReleaseAsset{
DownloadSource: &source,
OSSObjectKey: item.OSSObjectKey,
CustomDownloadURL: item.CustomDownloadURL,
FileName: item.FileName,
FileSizeBytes: item.FileSizeBytes,
SHA256: item.SHA256,
}
}
func itemUpdaterAsset(item *domain.DesktopClientRelease) desktopClientReleaseAsset {
if item == nil {
return desktopClientReleaseAsset{}
}
if item.UpdaterDownloadSource != nil {
return desktopClientReleaseAsset{
DownloadSource: item.UpdaterDownloadSource,
OSSObjectKey: item.UpdaterOSSObjectKey,
CustomDownloadURL: item.UpdaterCustomDownloadURL,
FileName: item.UpdaterFileName,
FileSizeBytes: item.UpdaterFileSizeBytes,
SHA256: item.UpdaterSHA256,
}
}
return itemInstallerAsset(item)
}
func (s *DesktopClientReleaseService) resolveDownloadURLResult(item *domain.DesktopClientRelease) (*DesktopClientReleaseDownloadURLResult, error) {
downloadURL, err := s.resolveInstallerDownloadURL(item)
if err != nil {
return nil, err
}
return &DesktopClientReleaseDownloadURLResult{DownloadURL: downloadURL}, nil
}
func (s *DesktopClientReleaseService) resolveInstallerDownloadURL(item *domain.DesktopClientRelease) (string, error) {
asset := itemInstallerAsset(item)
if !asset.HasTarget() {
return "", nil
}
if stringPointerValue(asset.DownloadSource) == DesktopClientDownloadSourceCustom {
return strings.TrimSpace(stringPointerValue(asset.CustomDownloadURL)), nil
}
objectKey := strings.TrimSpace(stringPointerValue(asset.OSSObjectKey))
if objectKey == "" {
return "", response.ErrInternal(50090, "desktop_client_release_missing_object_key", "OSS object key is missing")
}
if s.storage == nil {
return "", response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable")
}
downloadURL, err := s.storage.DownloadURL(objectKey)
if err != nil {
appErr := response.ErrInternal(50091, "object_storage_url_failed", err.Error())
appErr.Cause = err
return "", appErr
}
return downloadURL, nil
}
func (s *DesktopClientReleaseService) resolveAssetDownloadURL(asset desktopClientReleaseAsset) (string, error) {
if !asset.HasTarget() {
return "", nil
}
if stringPointerValue(asset.DownloadSource) == DesktopClientDownloadSourceCustom {
return strings.TrimSpace(stringPointerValue(asset.CustomDownloadURL)), nil
}
objectKey := strings.TrimSpace(stringPointerValue(asset.OSSObjectKey))
if objectKey == "" {
return "", response.ErrInternal(50090, "desktop_client_release_missing_object_key", "OSS object key is missing")
}
if s.storage == nil {
return "", response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable")
}
downloadURL, err := s.storage.PublicURL(objectKey)
if err != nil {
return "", response.ErrInternal(50091, "object_storage_url_failed", "failed to build object storage download url")
}
return downloadURL, nil
}
func buildDesktopClientUpdaterFeedYAML(item *domain.DesktopClientRelease, asset desktopClientReleaseAsset, downloadURL string) ([]byte, error) {
if item == nil {
return nil, response.ErrInternal(50093, "desktop_client_release_missing", "客户端版本配置不存在")
}
sha2 := strings.ToLower(strings.TrimSpace(stringPointerValue(asset.SHA256)))
if sha2 == "" {
return nil, response.ErrInternal(50094, "desktop_client_release_missing_sha256", "自动更新需要配置安装包 SHA256")
}
if !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(sha2) {
return nil, response.ErrInternal(50095, "desktop_client_release_invalid_sha256", "自动更新安装包 SHA256 格式无效")
}
file := desktopClientUpdaterFile{
URL: downloadURL,
SHA2: sha2,
Size: asset.FileSizeBytes,
}
releaseDate := item.UpdatedAt.UTC().Format(time.RFC3339)
if item.PublishedAt != nil {
releaseDate = item.PublishedAt.UTC().Format(time.RFC3339)
}
feed := desktopClientUpdaterFeed{
Version: item.Version,
Files: []desktopClientUpdaterFile{file},
Path: downloadURL,
SHA2: sha2,
ReleaseDate: releaseDate,
ReleaseName: item.Version,
}
if notes := strings.TrimSpace(stringPointerValue(item.ReleaseNotes)); notes != "" {
feed.ReleaseNotes = notes
}
content, err := yaml.Marshal(feed)
if err != nil {
return nil, fmt.Errorf("marshal desktop client updater feed: %w", err)
}
return content, nil
}
func validateDesktopClientUpdaterPackage(platform string, fileName *string, downloadURL string) error {
name := strings.ToLower(strings.TrimSpace(stringPointerValue(fileName)))
if name == "" {
name = strings.ToLower(resolveFileNameFromURL(downloadURL))
}
switch platform {
case "darwin":
if strings.HasSuffix(name, ".zip") {
return nil
}
return response.ErrInternal(50096, "desktop_client_update_package_not_supported", "macOS 自动更新必须上传 electron-builder 生成的 zip 包")
case "win32":
if strings.HasSuffix(name, ".exe") {
return nil
}
return response.ErrInternal(50096, "desktop_client_update_package_not_supported", "Windows 自动更新必须上传 electron-builder 生成的 NSIS exe 包")
case "linux":
if strings.HasSuffix(name, ".appimage") {
return nil
}
return response.ErrInternal(50096, "desktop_client_update_package_not_supported", "Linux 自动更新必须上传 electron-builder 生成的 AppImage 包")
default:
return response.ErrInternal(50096, "desktop_client_update_package_not_supported", "当前平台暂不支持自动更新")
}
}
func resolveFileNameFromURL(rawURL string) string {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return ""
}
name := path.Base(parsed.EscapedPath())
if decoded, err := url.PathUnescape(name); err == nil {
return decoded
}
return name
}
func sanitizeDesktopUpdaterFeedFile(value string) string {
value = strings.TrimPrefix(strings.TrimSpace(value), "/")
if value == "" {
return "latest.yml"
}
value = path.Base(value)
if !strings.HasSuffix(strings.ToLower(value), ".yml") {
return "latest.yml"
}
return value
}
func (s *DesktopClientReleaseService) audit(ctx context.Context, actor *Actor, action string, item *domain.DesktopClientRelease) {
if actor == nil || s.audits == nil || item == nil {
return
}
_ = s.audits.Append(ctx, actor.audit(action, "desktop_client_release", item.ID, desktopClientReleaseAuditMetadata(item)))
}
func (in DesktopClientReleaseInput) toRepositoryInput(actorID *int64) repository.UpsertDesktopClientReleaseInput {
return repository.UpsertDesktopClientReleaseInput{
Platform: in.Platform,
Arch: in.Arch,
Channel: in.Channel,
Version: in.Version,
MinSupportedVersion: in.MinSupportedVersion,
DownloadSource: in.DownloadSource,
OSSObjectKey: in.OSSObjectKey,
CustomDownloadURL: in.CustomDownloadURL,
FileName: in.FileName,
FileSizeBytes: in.FileSizeBytes,
SHA256: in.SHA256,
UpdaterDownloadSource: in.UpdaterDownloadSource,
UpdaterOSSObjectKey: in.UpdaterOSSObjectKey,
UpdaterCustomDownloadURL: in.UpdaterCustomDownloadURL,
UpdaterFileName: in.UpdaterFileName,
UpdaterFileSizeBytes: in.UpdaterFileSizeBytes,
UpdaterSHA256: in.UpdaterSHA256,
ReleaseNotes: in.ReleaseNotes,
ForceUpdate: in.ForceUpdate,
Enabled: in.Enabled,
PublishedAtNow: in.Enabled,
ActorID: actorID,
}
}
func normalizeDesktopClientReleaseInput(in DesktopClientReleaseInput) (DesktopClientReleaseInput, error) {
platform := normalizeDesktopReleaseEnum(in.Platform)
if !isAllowedDesktopReleasePlatform(platform) {
return DesktopClientReleaseInput{}, response.ErrBadRequest(40060, "invalid_desktop_release_platform", "客户端平台必须是 darwin、win32 或 linux")
}
arch := normalizeDesktopReleaseEnum(in.Arch)
if arch == "" {
arch = "universal"
}
if !isAllowedDesktopReleaseArch(arch) {
return DesktopClientReleaseInput{}, response.ErrBadRequest(40061, "invalid_desktop_release_arch", "客户端架构必须是 universal、x64、arm64 或 ia32")
}
channel := normalizeDesktopReleaseEnum(in.Channel)
if channel == "" {
channel = "stable"
}
if !isValidDesktopReleaseToken(channel) {
return DesktopClientReleaseInput{}, response.ErrBadRequest(40062, "invalid_desktop_release_channel", "发布渠道格式无效")
}
version := strings.TrimSpace(in.Version)
if !isValidDesktopReleaseVersion(version) {
return DesktopClientReleaseInput{}, response.ErrBadRequest(40063, "invalid_desktop_release_version", "版本号格式无效")
}
minSupportedVersion := trimOptionalReleaseString(in.MinSupportedVersion)
if minSupportedVersion != nil && !isValidDesktopReleaseVersion(*minSupportedVersion) {
return DesktopClientReleaseInput{}, response.ErrBadRequest(40064, "invalid_min_supported_version", "最低支持版本格式无效")
}
source := normalizeDesktopReleaseEnum(in.DownloadSource)
if source == "" {
source = DesktopClientDownloadSourceOSS
}
if source != DesktopClientDownloadSourceOSS && source != DesktopClientDownloadSourceCustom {
return DesktopClientReleaseInput{}, response.ErrBadRequest(40065, "invalid_download_source", "下载来源必须是 oss 或 custom")
}
installerAsset, err := normalizeDesktopClientReleaseAsset(
source,
in.OSSObjectKey,
in.CustomDownloadURL,
in.FileName,
in.FileSizeBytes,
in.SHA256,
true,
false,
platform,
)
if err != nil {
return DesktopClientReleaseInput{}, err
}
updaterSource := normalizeOptionalDesktopDownloadSource(in.UpdaterDownloadSource)
if updaterSource == nil && hasDesktopClientUpdaterAssetInput(in) {
defaultSource := DesktopClientDownloadSourceOSS
updaterSource = &defaultSource
}
var updaterAsset desktopClientReleaseAsset
if updaterSource != nil {
updaterAsset, err = normalizeDesktopClientReleaseAsset(
*updaterSource,
in.UpdaterOSSObjectKey,
in.UpdaterCustomDownloadURL,
in.UpdaterFileName,
in.UpdaterFileSizeBytes,
in.UpdaterSHA256,
true,
true,
platform,
)
if err != nil {
return DesktopClientReleaseInput{}, err
}
}
releaseNotes := trimOptionalReleaseString(in.ReleaseNotes)
return DesktopClientReleaseInput{
Platform: platform,
Arch: arch,
Channel: channel,
Version: version,
MinSupportedVersion: minSupportedVersion,
DownloadSource: source,
OSSObjectKey: installerAsset.OSSObjectKey,
CustomDownloadURL: installerAsset.CustomDownloadURL,
FileName: installerAsset.FileName,
FileSizeBytes: installerAsset.FileSizeBytes,
SHA256: installerAsset.SHA256,
UpdaterDownloadSource: updaterSource,
UpdaterOSSObjectKey: updaterAsset.OSSObjectKey,
UpdaterCustomDownloadURL: updaterAsset.CustomDownloadURL,
UpdaterFileName: updaterAsset.FileName,
UpdaterFileSizeBytes: updaterAsset.FileSizeBytes,
UpdaterSHA256: updaterAsset.SHA256,
ReleaseNotes: releaseNotes,
ForceUpdate: in.ForceUpdate,
Enabled: in.Enabled,
}, nil
}
func normalizeDesktopClientReleaseCheckInput(in DesktopClientReleaseCheckInput) (repository.DesktopClientReleaseTarget, string, error) {
platform := normalizeDesktopReleaseEnum(in.Platform)
if !isAllowedDesktopReleasePlatform(platform) {
return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40060, "invalid_desktop_release_platform", "客户端平台必须是 darwin、win32 或 linux")
}
arch := normalizeDesktopReleaseEnum(in.Arch)
if arch == "" {
arch = "universal"
}
if !isAllowedDesktopReleaseArch(arch) {
return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40061, "invalid_desktop_release_arch", "客户端架构必须是 universal、x64、arm64 或 ia32")
}
channel := normalizeDesktopReleaseEnum(in.Channel)
if channel == "" {
channel = "stable"
}
if !isValidDesktopReleaseToken(channel) {
return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40062, "invalid_desktop_release_channel", "发布渠道格式无效")
}
currentVersion := strings.TrimSpace(in.CurrentVersion)
if currentVersion != "" && !isValidDesktopReleaseVersion(currentVersion) {
return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40063, "invalid_desktop_release_version", "版本号格式无效")
}
return repository.DesktopClientReleaseTarget{Platform: platform, Arch: arch, Channel: channel}, currentVersion, nil
}
func normalizeOptionalDesktopDownloadSource(value *string) *string {
if value == nil {
return nil
}
source := normalizeDesktopReleaseEnum(*value)
if source == "" {
return nil
}
return &source
}
func hasDesktopClientUpdaterAssetInput(in DesktopClientReleaseInput) bool {
return trimOptionalReleaseString(in.UpdaterOSSObjectKey) != nil ||
trimOptionalReleaseString(in.UpdaterCustomDownloadURL) != nil ||
trimOptionalReleaseString(in.UpdaterFileName) != nil ||
in.UpdaterFileSizeBytes != nil ||
trimOptionalReleaseString(in.UpdaterSHA256) != nil
}
func normalizeDesktopClientReleaseAsset(
source string,
ossObjectKeyInput *string,
customDownloadURLInput *string,
fileNameInput *string,
fileSizeBytes *int64,
sha256Input *string,
required bool,
updater bool,
platform string,
) (desktopClientReleaseAsset, error) {
source = normalizeDesktopReleaseEnum(source)
if source == "" {
source = DesktopClientDownloadSourceOSS
}
if source != DesktopClientDownloadSourceOSS && source != DesktopClientDownloadSourceCustom {
return desktopClientReleaseAsset{}, response.ErrBadRequest(40065, "invalid_download_source", "下载来源必须是 oss 或 custom")
}
ossObjectKey := trimOptionalReleaseString(ossObjectKeyInput)
customDownloadURL := trimOptionalReleaseString(customDownloadURLInput)
if source == DesktopClientDownloadSourceOSS {
if required && (ossObjectKey == nil || !isValidDesktopReleaseObjectKey(*ossObjectKey)) {
return desktopClientReleaseAsset{}, response.ErrBadRequest(40066, "invalid_oss_object_key", "OSS Object Key 不能为空,且不能以 / 开头或包含 ..")
}
if ossObjectKey != nil && !isValidDesktopReleaseObjectKey(*ossObjectKey) {
return desktopClientReleaseAsset{}, response.ErrBadRequest(40066, "invalid_oss_object_key", "OSS Object Key 不能为空,且不能以 / 开头或包含 ..")
}
customDownloadURL = nil
} else {
if required && (customDownloadURL == nil || !isSafeHTTPURL(*customDownloadURL)) {
return desktopClientReleaseAsset{}, response.ErrBadRequest(40067, "invalid_custom_download_url", "自定义下载地址必须是 http(s) URL")
}
if customDownloadURL != nil && !isSafeHTTPURL(*customDownloadURL) {
return desktopClientReleaseAsset{}, response.ErrBadRequest(40067, "invalid_custom_download_url", "自定义下载地址必须是 http(s) URL")
}
ossObjectKey = nil
}
fileName := trimOptionalReleaseString(fileNameInput)
if fileName != nil && (len(*fileName) > 255 || strings.ContainsAny(*fileName, `/\`)) {
return desktopClientReleaseAsset{}, response.ErrBadRequest(40068, "invalid_file_name", "文件名不能包含路径且不能超过 255 字符")
}
if fileSizeBytes != nil && *fileSizeBytes < 0 {
return desktopClientReleaseAsset{}, response.ErrBadRequest(40069, "invalid_file_size", "文件大小不能为负数")
}
sha256, err := normalizeDesktopReleaseSHA256(sha256Input)
if err != nil {
return desktopClientReleaseAsset{}, err
}
if updater {
if sha256 == nil {
return desktopClientReleaseAsset{}, response.ErrBadRequest(40070, "invalid_sha256", "自动更新包必须配置 SHA256")
}
fileNameSource := stringPointerValue(customDownloadURL)
if fileNameSource == "" {
fileNameSource = stringPointerValue(ossObjectKey)
}
if err := validateDesktopClientUpdaterPackage(platform, fileName, fileNameSource); err != nil {
return desktopClientReleaseAsset{}, err
}
}
sourcePtr := source
return desktopClientReleaseAsset{
DownloadSource: &sourcePtr,
OSSObjectKey: ossObjectKey,
CustomDownloadURL: customDownloadURL,
FileName: fileName,
FileSizeBytes: fileSizeBytes,
SHA256: sha256,
}, nil
}
func normalizeDesktopReleaseSHA256(value *string) (*string, error) {
sha256 := trimOptionalReleaseString(value)
if sha256 == nil {
return nil, nil
}
normalized := strings.ToLower(*sha256)
if !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(normalized) {
return nil, response.ErrBadRequest(40070, "invalid_sha256", "SHA256 必须是 64 位十六进制")
}
return &normalized, nil
}
func normalizeDesktopClientReleaseUploadInput(in DesktopClientReleaseUploadInput) (repository.DesktopClientReleaseTarget, string, error) {
if len(in.Content) == 0 {
return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40071, "empty_release_file", "上传文件不能为空")
}
if len(in.Content) > 1024*1024*1024 {
return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40072, "release_file_too_large", "客户端安装包不能超过 1GB")
}
target, _, err := normalizeDesktopClientReleaseCheckInput(DesktopClientReleaseCheckInput{
Platform: in.Platform,
Arch: in.Arch,
Channel: in.Channel,
CurrentVersion: in.Version,
})
if err != nil {
return repository.DesktopClientReleaseTarget{}, "", err
}
fileName := sanitizeDesktopReleaseFileName(in.FileName)
if fileName == "" {
return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40068, "invalid_file_name", "文件名不能为空")
}
if !isAllowedDesktopReleasePackageFile(fileName) {
return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40073, "invalid_release_file_type", "安装包格式必须是 dmg、pkg、exe、msi、zip、AppImage、deb、rpm 或 tar.gz")
}
if normalizeDesktopClientAssetKind(in.Kind) == DesktopClientAssetKindUpdater {
if err := validateDesktopClientUpdaterPackage(target.Platform, &fileName, ""); err != nil {
return repository.DesktopClientReleaseTarget{}, "", err
}
}
return target, fileName, nil
}
func normalizeDesktopClientAssetKind(value string) string {
switch normalizeDesktopReleaseEnum(value) {
case DesktopClientAssetKindUpdater:
return DesktopClientAssetKindUpdater
default:
return DesktopClientAssetKindInstaller
}
}
func normalizeDesktopReleaseEnum(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func isAllowedDesktopReleasePlatform(value string) bool {
switch value {
case "darwin", "win32", "linux":
return true
default:
return false
}
}
func isAllowedDesktopReleaseArch(value string) bool {
switch value {
case "universal", "x64", "arm64", "ia32":
return true
default:
return false
}
}
func isValidDesktopReleaseToken(value string) bool {
if value == "" || len(value) > 32 {
return false
}
return regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`).MatchString(value)
}
func isValidDesktopReleaseVersion(value string) bool {
if value == "" || len(value) > 64 || strings.ContainsAny(value, " \t\r\n/\\") {
return false
}
return regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z.+_-]*$`).MatchString(value)
}
func isValidDesktopReleaseObjectKey(value string) bool {
if value == "" || len(value) > 1024 || strings.HasPrefix(value, "/") || strings.Contains(value, "\\") {
return false
}
for _, part := range strings.Split(value, "/") {
if part == "" || part == "." || part == ".." {
return false
}
}
return true
}
func isSafeHTTPURL(value string) bool {
parsed, err := url.Parse(value)
return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != ""
}
func sanitizeDesktopReleaseFileName(value string) string {
value = strings.TrimSpace(filepath.Base(value))
value = strings.ReplaceAll(value, "\\", "")
value = strings.TrimSpace(value)
if decoded, err := url.PathUnescape(value); err == nil {
value = strings.TrimSpace(decoded)
}
if value == "." || value == "/" || value == "\\" {
return ""
}
if len(value) > 255 {
return ""
}
return value
}
func isAllowedDesktopReleasePackageFile(fileName string) bool {
lower := strings.ToLower(strings.TrimSpace(fileName))
for _, suffix := range []string{
".dmg",
".pkg",
".exe",
".msi",
".zip",
".appimage",
".deb",
".rpm",
".tar.gz",
".tgz",
} {
if strings.HasSuffix(lower, suffix) {
return true
}
}
return false
}
func buildDesktopClientReleaseObjectKey(sha256Hex string, fileName string) string {
sum := strings.TrimSpace(strings.ToLower(sha256Hex))
if sum == "" {
sum = "unknown"
}
return fmt.Sprintf("desktop-client/releases/blobs/%s%s", sum, desktopReleaseFileExtension(fileName))
}
func desktopReleaseFileExtension(fileName string) string {
lower := strings.ToLower(strings.TrimSpace(fileName))
switch {
case strings.HasSuffix(lower, ".tar.gz"):
return ".tar.gz"
case strings.HasSuffix(lower, ".appimage"):
return ".AppImage"
default:
return strings.ToLower(filepath.Ext(fileName))
}
}
func detectDesktopReleaseContentType(fileName string, content []byte) string {
lower := strings.ToLower(fileName)
switch {
case strings.HasSuffix(lower, ".dmg"):
return "application/x-apple-diskimage"
case strings.HasSuffix(lower, ".pkg"):
return "application/octet-stream"
case strings.HasSuffix(lower, ".exe"):
return "application/vnd.microsoft.portable-executable"
case strings.HasSuffix(lower, ".msi"):
return "application/x-msi"
case strings.HasSuffix(lower, ".zip"):
return "application/zip"
case strings.HasSuffix(lower, ".deb"):
return "application/vnd.debian.binary-package"
case strings.HasSuffix(lower, ".rpm"):
return "application/x-rpm"
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"):
return "application/gzip"
default:
if len(content) > 0 {
return http.DetectContentType(content)
}
return "application/octet-stream"
}
}
func trimOptionalReleaseString(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func stringPointerValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func isDuplicateDesktopClientRelease(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
func desktopClientReleaseAuditMetadata(item *domain.DesktopClientRelease) map[string]any {
return map[string]any{
"platform": item.Platform,
"arch": item.Arch,
"channel": item.Channel,
"version": item.Version,
"download_source": item.DownloadSource,
"oss_object_key": stringPointerValue(item.OSSObjectKey),
"custom_download_url": stringPointerValue(item.CustomDownloadURL),
"force_update": item.ForceUpdate,
"enabled": item.Enabled,
}
}
func minSupportedVersionRequiresUpdate(minVersion *string, currentVersion string) bool {
if minVersion == nil || strings.TrimSpace(currentVersion) == "" {
return false
}
return compareVersionStrings(currentVersion, *minVersion) < 0
}
func compareVersionStrings(left string, right string) int {
leftParts := splitVersionParts(left)
rightParts := splitVersionParts(right)
maxLen := len(leftParts)
if len(rightParts) > maxLen {
maxLen = len(rightParts)
}
for i := 0; i < maxLen; i++ {
l, r := 0, 0
if i < len(leftParts) {
l = leftParts[i]
}
if i < len(rightParts) {
r = rightParts[i]
}
if l > r {
return 1
}
if l < r {
return -1
}
}
return strings.Compare(left, right)
}
func splitVersionParts(value string) []int {
value = strings.TrimPrefix(strings.TrimSpace(value), "v")
fields := regexp.MustCompile(`[^0-9]+`).Split(value, -1)
parts := make([]int, 0, len(fields))
for _, field := range fields {
if field == "" {
continue
}
n := 0
for _, ch := range field {
if ch < '0' || ch > '9' {
break
}
n = n*10 + int(ch-'0')
}
parts = append(parts, n)
}
return parts
}