Files
geo/server/internal/ops/app/desktop_client_release.go
T

2309 lines
78 KiB
Go

package app
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
"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"
)
const (
MaxDesktopClientReleaseUploadBytes int64 = 1 * 1024 * 1024 * 1024
desktopClientReleaseMultipartOverheadBytes = 4 * 1024 * 1024
DefaultDesktopClientReleaseChunkSizeBytes = 16 * 1024 * 1024
MinDesktopClientReleaseChunkSizeBytes = 1 * 1024 * 1024
MaxDesktopClientReleaseChunkSizeBytes = 64 * 1024 * 1024
desktopClientReleaseContentMD5Bytes = 16
desktopClientReleaseUploadSessionTTL = 24 * time.Hour
desktopClientReleaseDirectUploadTTL = 2 * time.Hour
)
type DesktopClientReleaseService struct {
releases *repository.DesktopClientReleaseRepository
storage objectstorage.Client
audits *AuditService
releaseUploadDir string
releaseUploadLocks map[string]*sync.Mutex
releaseUploadMu sync.Mutex
}
func NewDesktopClientReleaseService(
releases *repository.DesktopClientReleaseRepository,
storage objectstorage.Client,
audits *AuditService,
) *DesktopClientReleaseService {
return &DesktopClientReleaseService{
releases: releases,
storage: storage,
audits: audits,
releaseUploadDir: defaultDesktopClientReleaseUploadDir(),
releaseUploadLocks: make(map[string]*sync.Mutex),
}
}
func (s *DesktopClientReleaseService) WithReleaseUploadDir(dir string) *DesktopClientReleaseService {
if s == nil {
return s
}
s.releaseUploadDir = strings.TrimSpace(dir)
return s
}
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
ContentReader io.ReadSeeker
ContentSizeBytes int64
}
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 DesktopClientReleaseChunkedUploadInitInput struct {
Platform string
Arch string
Channel string
Version string
Kind string
FileName string
FileSizeBytes int64
ChunkSizeBytes int64
}
type DesktopClientReleaseChunkedUploadInitResult struct {
UploadID string `json:"upload_id"`
ChunkSizeBytes int64 `json:"chunk_size_bytes"`
ChunkCount int `json:"chunk_count"`
UploadedChunks []int `json:"uploaded_chunks"`
ExpiresAt time.Time `json:"expires_at"`
}
type DesktopClientReleaseChunkUploadInput struct {
UploadID string
ChunkIndex int
ContentReader io.Reader
ContentSizeBytes int64
}
type DesktopClientReleaseChunkUploadResult struct {
UploadID string `json:"upload_id"`
ChunkIndex int `json:"chunk_index"`
ReceivedBytes int64 `json:"received_bytes"`
UploadedChunks []int `json:"uploaded_chunks"`
}
type DesktopClientReleaseDirectUploadInitInput struct {
Platform string
Arch string
Channel string
Version string
Kind string
FileName string
FileSizeBytes int64
SHA256 string
ContentMD5 string
}
type DesktopClientReleaseDirectUploadInitResult struct {
UploadURL string `json:"upload_url,omitempty"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
OSSObjectKey string `json:"oss_object_key"`
FileName string `json:"file_name"`
FileSizeBytes int64 `json:"file_size_bytes"`
SHA256 string `json:"sha256"`
ObjectExists bool `json:"object_exists"`
ExpiresAt time.Time `json:"expires_at"`
}
type DesktopClientReleaseDirectUploadCompleteInput struct {
Platform string
Arch string
Channel string
Version string
Kind string
OSSObjectKey string
FileName string
FileSizeBytes int64
SHA256 string
}
type desktopClientReleaseUploadManifest struct {
UploadID string `json:"upload_id"`
Platform string `json:"platform"`
Arch string `json:"arch"`
Channel string `json:"channel"`
Version string `json:"version"`
Kind string `json:"kind"`
FileName string `json:"file_name"`
FileSizeBytes int64 `json:"file_size_bytes"`
ChunkSizeBytes int64 `json:"chunk_size_bytes"`
ChunkCount int `json:"chunk_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ExpiresAt time.Time `json:"expires_at"`
Chunks map[int]desktopClientReleaseUploadChunk `json:"chunks"`
}
type desktopClientReleaseUploadChunk struct {
Index int `json:"index"`
SizeBytes int64 `json:"size_bytes"`
SHA256 string `json:"sha256"`
UploadedAt time.Time `json:"uploaded_at"`
}
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) {
normalizedChannel := normalizeDesktopReleaseEnum(channel)
if normalizedChannel == "" {
normalizedChannel = "stable"
}
if !isValidDesktopReleaseToken(normalizedChannel) {
return nil, response.ErrBadRequest(40062, "invalid_desktop_release_channel", "发布渠道格式无效")
}
items, err := s.releases.ListEnabledByChannel(ctx, normalizedChannel)
if err != nil {
return nil, err
}
latestItems := latestDesktopClientReleasesByTarget(items)
views := make([]DesktopClientDownloadableRelease, 0, len(latestItems))
for i := range latestItems {
var downloadURL *string
if latestItems[i].DownloadSource == DesktopClientDownloadSourceCustom {
if value := strings.TrimSpace(stringPointerValue(latestItems[i].CustomDownloadURL)); value != "" {
downloadURL = &value
}
}
views = append(views, DesktopClientDownloadableRelease{
ID: latestItems[i].ID,
Platform: latestItems[i].Platform,
Arch: latestItems[i].Arch,
Channel: latestItems[i].Channel,
Version: latestItems[i].Version,
DownloadURL: downloadURL,
FileName: latestItems[i].FileName,
FileSizeBytes: latestItems[i].FileSizeBytes,
SHA256: latestItems[i].SHA256,
ReleaseNotes: latestItems[i].ReleaseNotes,
PublishedAt: latestItems[i].PublishedAt,
UpdatedAt: latestItems[i].UpdatedAt,
ForceUpdate: latestItems[i].ForceUpdate,
MinSupportedVersion: latestItems[i].MinSupportedVersion,
DownloadSource: latestItems[i].DownloadSource,
OSSObjectKey: latestItems[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
}
reader, fileSizeBytes, err := desktopClientReleaseUploadReader(in)
if err != nil {
return nil, response.ErrBadRequest(40071, "release_file_read_failed", "客户端安装包读取失败")
}
kind := normalizeDesktopClientAssetKind(in.Kind)
if kind == DesktopClientAssetKindUpdater {
if err := validateDesktopClientUpdaterPackage(target.Platform, &fileName, ""); err != nil {
return nil, err
}
}
sha256Hex, err := hashDesktopClientReleaseUpload(reader)
if err != nil {
return nil, response.ErrBadRequest(40071, "release_file_read_failed", "客户端安装包读取失败")
}
objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, fileName)
contentType := detectDesktopReleaseContentType(fileName, nil)
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 := putDesktopClientReleaseUpload(ctx, s.storage, objectKey, reader, fileSizeBytes, 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: fileSizeBytes,
SHA256: sha256Hex,
}, nil
}
func (s *DesktopClientReleaseService) InitiateDirectUpload(
ctx context.Context,
in DesktopClientReleaseDirectUploadInitInput,
) (*DesktopClientReleaseDirectUploadInitResult, 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")
}
uploadInput := DesktopClientReleaseUploadInput{
Platform: in.Platform,
Arch: in.Arch,
Channel: in.Channel,
Version: in.Version,
Kind: in.Kind,
FileName: in.FileName,
ContentSizeBytes: in.FileSizeBytes,
}
target, fileName, err := normalizeDesktopClientReleaseUploadInput(uploadInput)
if err != nil {
return nil, err
}
sha256Hex, err := normalizeRequiredDesktopReleaseSHA256(in.SHA256)
if err != nil {
return nil, err
}
kind := normalizeDesktopClientAssetKind(in.Kind)
if kind == DesktopClientAssetKindUpdater {
if err := validateDesktopClientUpdaterPackage(target.Platform, &fileName, ""); err != nil {
return nil, err
}
}
if _, err := normalizeDesktopReleaseContentMD5(in.ContentMD5); err != nil {
return nil, err
}
objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, fileName)
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 {
return &DesktopClientReleaseDirectUploadInitResult{
Method: http.MethodPut,
Headers: map[string]string{},
OSSObjectKey: objectKey,
FileName: fileName,
FileSizeBytes: in.FileSizeBytes,
SHA256: sha256Hex,
ObjectExists: true,
ExpiresAt: time.Now().UTC(),
}, nil
}
presigner, ok := s.storage.(objectstorage.PresignedPutClient)
if !ok {
return nil, response.ErrInternal(50091, "object_storage_direct_upload_unsupported", "object storage direct upload is unsupported")
}
presigned, err := presigner.PresignedPutURL(
ctx,
objectKey,
detectDesktopReleaseContentType(fileName, nil),
"",
desktopClientReleaseDirectUploadTTL,
)
if err != nil {
appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error())
appErr.Cause = err
return nil, appErr
}
return &DesktopClientReleaseDirectUploadInitResult{
UploadURL: presigned.URL,
Method: http.MethodPut,
Headers: flattenHTTPHeaders(presigned.Headers),
OSSObjectKey: objectKey,
FileName: fileName,
FileSizeBytes: in.FileSizeBytes,
SHA256: sha256Hex,
ObjectExists: false,
ExpiresAt: presigned.ExpiresAt,
}, nil
}
func (s *DesktopClientReleaseService) CompleteDirectUpload(
ctx context.Context,
in DesktopClientReleaseDirectUploadCompleteInput,
) (*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")
}
uploadInput := DesktopClientReleaseUploadInput{
Platform: in.Platform,
Arch: in.Arch,
Channel: in.Channel,
Version: in.Version,
Kind: in.Kind,
FileName: in.FileName,
ContentSizeBytes: in.FileSizeBytes,
}
target, fileName, err := normalizeDesktopClientReleaseUploadInput(uploadInput)
if err != nil {
return nil, err
}
sha256Hex, err := normalizeRequiredDesktopReleaseSHA256(in.SHA256)
if err != nil {
return nil, err
}
kind := normalizeDesktopClientAssetKind(in.Kind)
if kind == DesktopClientAssetKindUpdater {
if err := validateDesktopClientUpdaterPackage(target.Platform, &fileName, ""); err != nil {
return nil, err
}
}
objectKey := strings.TrimSpace(in.OSSObjectKey)
expectedObjectKey := buildDesktopClientReleaseObjectKey(sha256Hex, fileName)
if objectKey == "" {
objectKey = expectedObjectKey
}
if objectKey != expectedObjectKey || !isValidDesktopReleaseObjectKey(objectKey) {
return nil, response.ErrBadRequest(40066, "invalid_oss_object_key", "OSS Object Key 与上传文件校验信息不匹配")
}
if management, ok := s.storage.(objectstorage.ManagementClient); ok {
info, err := management.Stat(ctx, objectKey)
if err != nil {
if errors.Is(err, objectstorage.ErrObjectNotFound) {
return nil, response.ErrBadRequest(40078, "direct_upload_not_found", "OSS 直传文件尚未完成")
}
appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error())
appErr.Cause = err
return nil, appErr
}
if info.Size != in.FileSizeBytes {
return nil, response.ErrBadRequest(40079, "direct_upload_size_mismatch", "OSS 直传文件大小不匹配,请重新上传")
}
} else {
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 {
return nil, response.ErrBadRequest(40078, "direct_upload_not_found", "OSS 直传文件尚未完成")
}
}
actualSHA, actualSize, err := hashDesktopClientReleaseStorageObject(ctx, s.storage, objectKey)
if err != nil {
if errors.Is(err, objectstorage.ErrObjectNotFound) {
return nil, response.ErrBadRequest(40078, "direct_upload_not_found", "OSS 直传文件尚未完成")
}
appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error())
appErr.Cause = err
return nil, appErr
}
if actualSize != in.FileSizeBytes {
return nil, response.ErrBadRequest(40079, "direct_upload_size_mismatch", "OSS 直传文件大小不匹配,请重新上传")
}
if actualSHA != sha256Hex {
return nil, response.ErrBadRequest(40070, "invalid_sha256", "OSS 直传文件 SHA256 不匹配,请重新上传")
}
return &DesktopClientReleaseUploadResult{
OSSObjectKey: objectKey,
FileName: fileName,
FileSizeBytes: in.FileSizeBytes,
SHA256: sha256Hex,
}, nil
}
func hashDesktopClientReleaseStorageObject(
ctx context.Context,
storage objectstorage.Client,
objectKey string,
) (string, int64, error) {
if readerClient, ok := storage.(objectstorage.ObjectReaderClient); ok {
reader, err := readerClient.GetReader(ctx, objectKey)
if err != nil {
return "", 0, err
}
defer reader.Close()
hasher := sha256.New()
size, err := io.Copy(hasher, reader)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(hasher.Sum(nil)), size, nil
}
content, err := storage.GetBytes(ctx, objectKey)
if err != nil {
return "", 0, err
}
actualSHA := sha256.Sum256(content)
return hex.EncodeToString(actualSHA[:]), int64(len(content)), nil
}
func MaxDesktopClientReleaseMultipartBytes() int64 {
return MaxDesktopClientReleaseUploadBytes + desktopClientReleaseMultipartOverheadBytes
}
func MaxDesktopClientReleaseChunkMultipartBytes() int64 {
return MaxDesktopClientReleaseChunkSizeBytes + desktopClientReleaseMultipartOverheadBytes
}
func (s *DesktopClientReleaseService) InitiateChunkedUpload(
ctx context.Context,
in DesktopClientReleaseChunkedUploadInitInput,
) (*DesktopClientReleaseChunkedUploadInitResult, 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")
}
uploadInput := DesktopClientReleaseUploadInput{
Platform: in.Platform,
Arch: in.Arch,
Channel: in.Channel,
Version: in.Version,
Kind: in.Kind,
FileName: in.FileName,
ContentSizeBytes: in.FileSizeBytes,
}
target, fileName, err := normalizeDesktopClientReleaseUploadInput(uploadInput)
if err != nil {
return nil, err
}
chunkSize := normalizeDesktopClientReleaseChunkSize(in.ChunkSizeBytes)
chunkCount := desktopClientReleaseChunkCount(in.FileSizeBytes, chunkSize)
if chunkCount <= 0 {
return nil, response.ErrBadRequest(40071, "empty_release_file", "上传文件不能为空")
}
_ = s.cleanupExpiredDesktopReleaseUploads(time.Now())
uploadID := uuid.NewString()
now := time.Now().UTC()
manifest := desktopClientReleaseUploadManifest{
UploadID: uploadID,
Platform: target.Platform,
Arch: target.Arch,
Channel: target.Channel,
Version: strings.TrimSpace(in.Version),
Kind: normalizeDesktopClientAssetKind(in.Kind),
FileName: fileName,
FileSizeBytes: in.FileSizeBytes,
ChunkSizeBytes: chunkSize,
ChunkCount: chunkCount,
CreatedAt: now,
UpdatedAt: now,
ExpiresAt: now.Add(desktopClientReleaseUploadSessionTTL),
Chunks: map[int]desktopClientReleaseUploadChunk{},
}
lock := s.desktopReleaseUploadLock(uploadID)
lock.Lock()
defer lock.Unlock()
if err := os.MkdirAll(s.desktopReleaseUploadChunkDir(uploadID), 0o700); err != nil {
return nil, response.ErrInternal(50092, "desktop_client_release_upload_failed", "failed to create upload session")
}
if err := s.saveDesktopReleaseUploadManifest(manifest); err != nil {
return nil, response.ErrInternal(50092, "desktop_client_release_upload_failed", "failed to save upload session")
}
return &DesktopClientReleaseChunkedUploadInitResult{
UploadID: uploadID,
ChunkSizeBytes: chunkSize,
ChunkCount: chunkCount,
UploadedChunks: []int{},
ExpiresAt: manifest.ExpiresAt,
}, nil
}
func (s *DesktopClientReleaseService) UploadChunk(
ctx context.Context,
in DesktopClientReleaseChunkUploadInput,
) (*DesktopClientReleaseChunkUploadResult, error) {
uploadID, err := normalizeDesktopClientReleaseUploadID(in.UploadID)
if err != nil {
return nil, err
}
if in.ContentReader == nil {
return nil, response.ErrBadRequest(40071, "release_file_required", "请选择要上传的客户端安装包")
}
lock := s.desktopReleaseUploadLock(uploadID)
lock.Lock()
defer lock.Unlock()
manifest, err := s.loadDesktopReleaseUploadManifest(uploadID)
if err != nil {
return nil, err
}
if time.Now().UTC().After(manifest.ExpiresAt) {
return nil, response.ErrBadRequest(40075, "release_upload_expired", "上传会话已过期,请重新选择文件上传")
}
if in.ChunkIndex < 0 || in.ChunkIndex >= manifest.ChunkCount {
return nil, response.ErrBadRequest(40076, "invalid_release_upload_chunk", "上传分片序号无效")
}
expectedSize := manifest.expectedChunkSize(in.ChunkIndex)
if in.ContentSizeBytes > 0 && in.ContentSizeBytes != expectedSize {
return nil, response.ErrBadRequest(40076, "invalid_release_upload_chunk", "上传分片大小无效")
}
chunkDir := s.desktopReleaseUploadChunkDir(uploadID)
if err := os.MkdirAll(chunkDir, 0o700); err != nil {
return nil, response.ErrInternal(50092, "desktop_client_release_upload_failed", "failed to create upload chunk directory")
}
chunkPath := desktopReleaseUploadChunkPath(chunkDir, in.ChunkIndex)
tmpPath := chunkPath + ".tmp"
chunkFile, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return nil, response.ErrInternal(50092, "desktop_client_release_upload_failed", "failed to write upload chunk")
}
hasher := sha256.New()
written, copyErr := io.Copy(io.MultiWriter(chunkFile, hasher), io.LimitReader(in.ContentReader, expectedSize+1))
closeErr := chunkFile.Close()
if copyErr != nil {
_ = os.Remove(tmpPath)
return nil, response.ErrBadRequest(40071, "release_file_read_failed", "客户端安装包读取失败")
}
if closeErr != nil {
_ = os.Remove(tmpPath)
return nil, response.ErrInternal(50092, "desktop_client_release_upload_failed", "failed to write upload chunk")
}
if written != expectedSize {
_ = os.Remove(tmpPath)
return nil, response.ErrBadRequest(40076, "invalid_release_upload_chunk", "上传分片大小无效")
}
if err := os.Rename(tmpPath, chunkPath); err != nil {
_ = os.Remove(tmpPath)
return nil, response.ErrInternal(50092, "desktop_client_release_upload_failed", "failed to save upload chunk")
}
now := time.Now().UTC()
manifest.UpdatedAt = now
manifest.Chunks[in.ChunkIndex] = desktopClientReleaseUploadChunk{
Index: in.ChunkIndex,
SizeBytes: written,
SHA256: hex.EncodeToString(hasher.Sum(nil)),
UploadedAt: now,
}
if err := s.saveDesktopReleaseUploadManifest(manifest); err != nil {
return nil, response.ErrInternal(50092, "desktop_client_release_upload_failed", "failed to update upload session")
}
return &DesktopClientReleaseChunkUploadResult{
UploadID: uploadID,
ChunkIndex: in.ChunkIndex,
ReceivedBytes: written,
UploadedChunks: manifest.uploadedChunkIndexes(),
}, nil
}
func (s *DesktopClientReleaseService) CompleteChunkedUpload(
ctx context.Context,
uploadIDValue string,
) (*DesktopClientReleaseUploadResult, error) {
uploadID, err := normalizeDesktopClientReleaseUploadID(uploadIDValue)
if err != nil {
return nil, err
}
lock := s.desktopReleaseUploadLock(uploadID)
lock.Lock()
defer lock.Unlock()
manifest, err := s.loadDesktopReleaseUploadManifest(uploadID)
if err != nil {
return nil, err
}
if time.Now().UTC().After(manifest.ExpiresAt) {
return nil, response.ErrBadRequest(40075, "release_upload_expired", "上传会话已过期,请重新选择文件上传")
}
if err := manifest.validateComplete(); err != nil {
return nil, err
}
sha256Hex, err := s.hashDesktopReleaseUploadChunks(manifest)
if err != nil {
return nil, err
}
objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, manifest.FileName)
contentType := detectDesktopReleaseContentType(manifest.FileName, nil)
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 {
reader, err := s.openDesktopReleaseUploadChunks(manifest)
if err != nil {
return nil, err
}
defer reader.Close()
if err := putDesktopClientReleaseReader(ctx, s.storage, objectKey, reader, manifest.FileSizeBytes, contentType); err != nil {
appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error())
appErr.Cause = err
return nil, appErr
}
}
result := &DesktopClientReleaseUploadResult{
OSSObjectKey: objectKey,
FileName: manifest.FileName,
FileSizeBytes: manifest.FileSizeBytes,
SHA256: sha256Hex,
}
_ = os.RemoveAll(s.desktopReleaseUploadSessionDir(uploadID))
s.forgetDesktopReleaseUploadLock(uploadID)
return result, nil
}
func (s *DesktopClientReleaseService) AbortChunkedUpload(uploadIDValue string) error {
uploadID, err := normalizeDesktopClientReleaseUploadID(uploadIDValue)
if err != nil {
return err
}
lock := s.desktopReleaseUploadLock(uploadID)
lock.Lock()
defer lock.Unlock()
_ = os.RemoveAll(s.desktopReleaseUploadSessionDir(uploadID))
s.forgetDesktopReleaseUploadLock(uploadID)
return 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.findLatestEnabledRelease(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.findLatestEnabledRelease(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.findLatestEnabledRelease(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 (s *DesktopClientReleaseService) findLatestEnabledRelease(ctx context.Context, target repository.DesktopClientReleaseTarget) (*domain.DesktopClientRelease, error) {
items, err := s.releases.ListEnabledByTarget(ctx, target)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, repository.ErrDesktopClientReleaseNotFound
}
return latestDesktopClientRelease(items), nil
}
func latestDesktopClientReleasesByTarget(items []domain.DesktopClientRelease) []domain.DesktopClientRelease {
byTarget := map[string]domain.DesktopClientRelease{}
for _, item := range items {
key := item.Platform + "\x00" + item.Arch + "\x00" + item.Channel
current, ok := byTarget[key]
if !ok || isDesktopClientReleaseNewer(item, current) {
byTarget[key] = item
}
}
latest := make([]domain.DesktopClientRelease, 0, len(byTarget))
for _, item := range byTarget {
latest = append(latest, item)
}
sort.SliceStable(latest, func(i, j int) bool {
if latest[i].Platform != latest[j].Platform {
return latest[i].Platform < latest[j].Platform
}
if latest[i].Arch != latest[j].Arch {
return latest[i].Arch < latest[j].Arch
}
if latest[i].Channel != latest[j].Channel {
return latest[i].Channel < latest[j].Channel
}
return latest[i].ID > latest[j].ID
})
return latest
}
func latestDesktopClientRelease(items []domain.DesktopClientRelease) *domain.DesktopClientRelease {
if len(items) == 0 {
return nil
}
latest := items[0]
for _, item := range items[1:] {
if isDesktopClientReleaseNewer(item, latest) {
latest = item
}
}
return &latest
}
func isDesktopClientReleaseNewer(candidate, current domain.DesktopClientRelease) bool {
if archPriority(candidate.Arch) != archPriority(current.Arch) {
return archPriority(candidate.Arch) < archPriority(current.Arch)
}
if compared := compareVersionStrings(candidate.Version, current.Version); compared != 0 {
return compared > 0
}
if !candidate.UpdatedAt.Equal(current.UpdatedAt) {
return candidate.UpdatedAt.After(current.UpdatedAt)
}
return candidate.ID > current.ID
}
func archPriority(arch string) int {
if arch == "universal" {
return 1
}
return 0
}
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 normalizeRequiredDesktopReleaseSHA256(value string) (string, error) {
sha256, err := normalizeDesktopReleaseSHA256(&value)
if err != nil {
return "", err
}
if sha256 == nil {
return "", response.ErrBadRequest(40070, "invalid_sha256", "SHA256 必须是 64 位十六进制")
}
return *sha256, nil
}
func normalizeDesktopReleaseContentMD5(value string) (string, error) {
normalized := strings.TrimSpace(value)
if normalized == "" {
return "", nil
}
content, err := base64.StdEncoding.DecodeString(normalized)
if err != nil || len(content) != desktopClientReleaseContentMD5Bytes {
return "", response.ErrBadRequest(40080, "invalid_content_md5", "Content-MD5 必须是标准 base64 编码的 16 字节 MD5")
}
return normalized, nil
}
func normalizeDesktopClientReleaseUploadInput(in DesktopClientReleaseUploadInput) (repository.DesktopClientReleaseTarget, string, error) {
size, err := desktopClientReleaseUploadSize(in)
if err != nil {
return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40071, "release_file_read_failed", "客户端安装包读取失败")
}
if size == 0 {
return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40071, "empty_release_file", "上传文件不能为空")
}
if size > MaxDesktopClientReleaseUploadBytes {
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 desktopClientReleaseUploadSize(in DesktopClientReleaseUploadInput) (int64, error) {
if in.ContentReader == nil {
if in.ContentSizeBytes > 0 {
return in.ContentSizeBytes, nil
}
return int64(len(in.Content)), nil
}
if in.ContentSizeBytes > 0 {
return in.ContentSizeBytes, nil
}
current, err := in.ContentReader.Seek(0, io.SeekCurrent)
if err != nil {
return 0, err
}
end, err := in.ContentReader.Seek(0, io.SeekEnd)
if restoreErr := seekDesktopClientReleaseUpload(in.ContentReader, current); err == nil {
err = restoreErr
}
if err != nil {
return 0, err
}
return end, nil
}
func desktopClientReleaseUploadReader(in DesktopClientReleaseUploadInput) (io.ReadSeeker, int64, error) {
size, err := desktopClientReleaseUploadSize(in)
if err != nil {
return nil, 0, err
}
if in.ContentReader != nil {
if err := seekDesktopClientReleaseUpload(in.ContentReader, 0); err != nil {
return nil, 0, err
}
return in.ContentReader, size, nil
}
return bytes.NewReader(in.Content), size, nil
}
func hashDesktopClientReleaseUpload(reader io.ReadSeeker) (string, error) {
if err := seekDesktopClientReleaseUpload(reader, 0); err != nil {
return "", err
}
hasher := sha256.New()
if _, err := io.Copy(hasher, reader); err != nil {
return "", err
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
func putDesktopClientReleaseUpload(
ctx context.Context,
storage objectstorage.Client,
objectKey string,
reader io.ReadSeeker,
size int64,
contentType string,
) error {
if err := seekDesktopClientReleaseUpload(reader, 0); err != nil {
return err
}
return putDesktopClientReleaseReader(ctx, storage, objectKey, reader, size, contentType)
}
func putDesktopClientReleaseReader(
ctx context.Context,
storage objectstorage.Client,
objectKey string,
reader io.Reader,
size int64,
contentType string,
) error {
if readerClient, ok := storage.(objectstorage.ReaderClient); ok {
return readerClient.PutReader(ctx, objectKey, reader, size, contentType)
}
content, err := io.ReadAll(io.LimitReader(reader, MaxDesktopClientReleaseUploadBytes+1))
if err != nil {
return err
}
if int64(len(content)) > MaxDesktopClientReleaseUploadBytes {
return response.ErrBadRequest(40072, "release_file_too_large", "客户端安装包不能超过 1GB")
}
return storage.PutBytes(ctx, objectKey, content, contentType)
}
func flattenHTTPHeaders(headers http.Header) map[string]string {
if len(headers) == 0 {
return map[string]string{}
}
out := make(map[string]string, len(headers))
for key, values := range headers {
key = strings.TrimSpace(key)
if key == "" || len(values) == 0 {
continue
}
value := strings.TrimSpace(values[0])
if value == "" {
continue
}
out[key] = value
}
return out
}
func defaultDesktopClientReleaseUploadDir() string {
return filepath.Join(os.TempDir(), "geo-rankly-desktop-release-uploads")
}
func normalizeDesktopClientReleaseChunkSize(value int64) int64 {
switch {
case value <= 0:
return DefaultDesktopClientReleaseChunkSizeBytes
case value < MinDesktopClientReleaseChunkSizeBytes:
return MinDesktopClientReleaseChunkSizeBytes
case value > MaxDesktopClientReleaseChunkSizeBytes:
return MaxDesktopClientReleaseChunkSizeBytes
default:
return value
}
}
func desktopClientReleaseChunkCount(fileSizeBytes int64, chunkSizeBytes int64) int {
if fileSizeBytes <= 0 || chunkSizeBytes <= 0 {
return 0
}
return int((fileSizeBytes + chunkSizeBytes - 1) / chunkSizeBytes)
}
func normalizeDesktopClientReleaseUploadID(value string) (string, error) {
uploadID := strings.TrimSpace(value)
if _, err := uuid.Parse(uploadID); err != nil {
return "", response.ErrBadRequest(40074, "invalid_release_upload_id", "上传会话不存在或已失效")
}
return uploadID, nil
}
func (s *DesktopClientReleaseService) desktopReleaseUploadBaseDir() string {
dir := strings.TrimSpace(s.releaseUploadDir)
if dir == "" {
return defaultDesktopClientReleaseUploadDir()
}
return dir
}
func (s *DesktopClientReleaseService) desktopReleaseUploadSessionDir(uploadID string) string {
return filepath.Join(s.desktopReleaseUploadBaseDir(), uploadID)
}
func (s *DesktopClientReleaseService) desktopReleaseUploadChunkDir(uploadID string) string {
return filepath.Join(s.desktopReleaseUploadSessionDir(uploadID), "chunks")
}
func (s *DesktopClientReleaseService) desktopReleaseUploadManifestPath(uploadID string) string {
return filepath.Join(s.desktopReleaseUploadSessionDir(uploadID), "manifest.json")
}
func desktopReleaseUploadChunkPath(chunkDir string, index int) string {
return filepath.Join(chunkDir, fmt.Sprintf("%06d.part", index))
}
func (s *DesktopClientReleaseService) desktopReleaseUploadLock(uploadID string) *sync.Mutex {
s.releaseUploadMu.Lock()
defer s.releaseUploadMu.Unlock()
if s.releaseUploadLocks == nil {
s.releaseUploadLocks = make(map[string]*sync.Mutex)
}
if lock := s.releaseUploadLocks[uploadID]; lock != nil {
return lock
}
lock := &sync.Mutex{}
s.releaseUploadLocks[uploadID] = lock
return lock
}
func (s *DesktopClientReleaseService) forgetDesktopReleaseUploadLock(uploadID string) {
s.releaseUploadMu.Lock()
defer s.releaseUploadMu.Unlock()
delete(s.releaseUploadLocks, uploadID)
}
func (s *DesktopClientReleaseService) saveDesktopReleaseUploadManifest(manifest desktopClientReleaseUploadManifest) error {
if manifest.Chunks == nil {
manifest.Chunks = map[int]desktopClientReleaseUploadChunk{}
}
sessionDir := s.desktopReleaseUploadSessionDir(manifest.UploadID)
if err := os.MkdirAll(sessionDir, 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
manifestPath := s.desktopReleaseUploadManifestPath(manifest.UploadID)
tmpPath := manifestPath + ".tmp"
if err := os.WriteFile(tmpPath, data, 0o600); err != nil {
return err
}
return os.Rename(tmpPath, manifestPath)
}
func (s *DesktopClientReleaseService) loadDesktopReleaseUploadManifest(uploadID string) (desktopClientReleaseUploadManifest, error) {
data, err := os.ReadFile(s.desktopReleaseUploadManifestPath(uploadID))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return desktopClientReleaseUploadManifest{}, response.ErrBadRequest(40074, "invalid_release_upload_id", "上传会话不存在或已失效")
}
return desktopClientReleaseUploadManifest{}, response.ErrInternal(50092, "desktop_client_release_upload_failed", "failed to read upload session")
}
var manifest desktopClientReleaseUploadManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return desktopClientReleaseUploadManifest{}, response.ErrBadRequest(40074, "invalid_release_upload_id", "上传会话不存在或已失效")
}
if manifest.UploadID != uploadID || manifest.Chunks == nil {
return desktopClientReleaseUploadManifest{}, response.ErrBadRequest(40074, "invalid_release_upload_id", "上传会话不存在或已失效")
}
return manifest, nil
}
func (s *DesktopClientReleaseService) cleanupExpiredDesktopReleaseUploads(now time.Time) error {
baseDir := s.desktopReleaseUploadBaseDir()
entries, err := os.ReadDir(baseDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
uploadID := entry.Name()
if _, err := normalizeDesktopClientReleaseUploadID(uploadID); err != nil {
continue
}
manifest, err := s.loadDesktopReleaseUploadManifest(uploadID)
if err != nil || now.UTC().After(manifest.ExpiresAt) {
_ = os.RemoveAll(s.desktopReleaseUploadSessionDir(uploadID))
s.forgetDesktopReleaseUploadLock(uploadID)
}
}
return nil
}
func (m desktopClientReleaseUploadManifest) expectedChunkSize(index int) int64 {
if index < 0 || index >= m.ChunkCount || m.ChunkSizeBytes <= 0 {
return 0
}
if index == m.ChunkCount-1 {
return m.FileSizeBytes - int64(index)*m.ChunkSizeBytes
}
return m.ChunkSizeBytes
}
func (m desktopClientReleaseUploadManifest) uploadedChunkIndexes() []int {
indexes := make([]int, 0, len(m.Chunks))
for index := range m.Chunks {
indexes = append(indexes, index)
}
sort.Ints(indexes)
return indexes
}
func (m desktopClientReleaseUploadManifest) validateComplete() error {
if m.ChunkCount <= 0 || len(m.Chunks) != m.ChunkCount {
return response.ErrBadRequest(40077, "release_upload_incomplete", "上传分片尚未全部完成")
}
for index := 0; index < m.ChunkCount; index++ {
chunk, ok := m.Chunks[index]
if !ok || chunk.SizeBytes != m.expectedChunkSize(index) {
return response.ErrBadRequest(40077, "release_upload_incomplete", "上传分片尚未全部完成")
}
}
return nil
}
func (s *DesktopClientReleaseService) hashDesktopReleaseUploadChunks(
manifest desktopClientReleaseUploadManifest,
) (string, error) {
hasher := sha256.New()
chunkDir := s.desktopReleaseUploadChunkDir(manifest.UploadID)
for index := 0; index < manifest.ChunkCount; index++ {
chunk := manifest.Chunks[index]
if err := copyDesktopReleaseChunkToWriter(hasher, desktopReleaseUploadChunkPath(chunkDir, index), manifest.expectedChunkSize(index), chunk.SHA256); err != nil {
return "", err
}
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
func copyDesktopReleaseChunkToWriter(dst io.Writer, chunkPath string, expectedSize int64, expectedSHA256 string) error {
file, err := os.Open(chunkPath)
if err != nil {
return response.ErrBadRequest(40077, "release_upload_incomplete", "上传分片尚未全部完成")
}
defer file.Close()
hasher := sha256.New()
written, err := io.Copy(io.MultiWriter(dst, hasher), file)
if err != nil {
return response.ErrBadRequest(40071, "release_file_read_failed", "客户端安装包读取失败")
}
if written != expectedSize {
return response.ErrBadRequest(40077, "release_upload_incomplete", "上传分片尚未全部完成")
}
if sha256Hex := hex.EncodeToString(hasher.Sum(nil)); expectedSHA256 != "" && sha256Hex != expectedSHA256 {
return response.ErrBadRequest(40077, "release_upload_incomplete", "上传分片校验失败,请重新上传")
}
return nil
}
func (s *DesktopClientReleaseService) openDesktopReleaseUploadChunks(
manifest desktopClientReleaseUploadManifest,
) (*desktopClientReleaseChunkReadCloser, error) {
chunkDir := s.desktopReleaseUploadChunkDir(manifest.UploadID)
closers := make([]io.Closer, 0, manifest.ChunkCount)
readers := make([]io.Reader, 0, manifest.ChunkCount)
for index := 0; index < manifest.ChunkCount; index++ {
chunkPath := desktopReleaseUploadChunkPath(chunkDir, index)
info, err := os.Stat(chunkPath)
if err != nil || info.Size() != manifest.expectedChunkSize(index) {
for _, closer := range closers {
_ = closer.Close()
}
return nil, response.ErrBadRequest(40077, "release_upload_incomplete", "上传分片尚未全部完成")
}
file, err := os.Open(chunkPath)
if err != nil {
for _, closer := range closers {
_ = closer.Close()
}
return nil, response.ErrBadRequest(40077, "release_upload_incomplete", "上传分片尚未全部完成")
}
closers = append(closers, file)
readers = append(readers, file)
}
return &desktopClientReleaseChunkReadCloser{
reader: io.MultiReader(readers...),
closers: closers,
}, nil
}
type desktopClientReleaseChunkReadCloser struct {
reader io.Reader
closers []io.Closer
}
func (r *desktopClientReleaseChunkReadCloser) Read(p []byte) (int, error) {
return r.reader.Read(p)
}
func (r *desktopClientReleaseChunkReadCloser) Close() error {
var closeErr error
for _, closer := range r.closers {
if err := closer.Close(); err != nil && closeErr == nil {
closeErr = err
}
}
return closeErr
}
func seekDesktopClientReleaseUpload(reader io.ReadSeeker, offset int64) error {
if reader == nil {
return fmt.Errorf("release upload reader is required")
}
_, err := reader.Seek(offset, io.SeekStart)
return err
}
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
}