2309 lines
78 KiB
Go
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
|
|
}
|