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